From 0d0a65d5f8536ce793c0e52c24ce57ea4b52724b Mon Sep 17 00:00:00 2001 From: Pornpipat Popum Date: Wed, 25 Mar 2026 12:47:18 +0700 Subject: [PATCH 001/189] Initial commit --- .gitignore | 21 +++++++++++++++++++++ README.md | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 .gitignore create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad67955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..46252d6 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# odorobo +Stack Virtualization Platform - Powered by Cloud Hypervisor From fb9583ab1e3daa70f91c0eb77042a0e9a2b3be51 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Wed, 25 Mar 2026 15:09:17 +0700 Subject: [PATCH 002/189] stub config --- .zed/settings.json | 9 + Cargo.lock | 309 ++++++++++++++ Cargo.toml | 9 + LICENSE | 661 +++++++++++++++++++++++++++++ README.md | 9 +- odorobo-agent/Cargo.toml | 10 + odorobo-agent/src/main.rs | 11 + odorobo-agent/src/state.rs | 56 +++ systemd/odorobo-ch@.service | 76 ++++ systemd/scripts/odorobo-cleanup | 7 + systemd/scripts/odorobo-postflight | 7 + systemd/scripts/odorobo-preflight | 7 + 12 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 .zed/settings.json create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 odorobo-agent/Cargo.toml create mode 100644 odorobo-agent/src/main.rs create mode 100644 odorobo-agent/src/state.rs create mode 100644 systemd/odorobo-ch@.service create mode 100644 systemd/scripts/odorobo-cleanup create mode 100644 systemd/scripts/odorobo-postflight create mode 100644 systemd/scripts/odorobo-preflight diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..886504e --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,9 @@ +{ + // Folder-specific settings + // + // For a full list of overridable settings, and general information on folder-specific settings,//zed.dev/docs/configuring-zed#settings-files + "project_name": "Odorobo", + "file_types": { + "shell": ["systemd/scripts/*"], + }, +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6936fe7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,309 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "odorobo-agent" +version = "0.1.0" +dependencies = [ + "stable-eyre", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable-eyre" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556fec8c2da34c70b75f16d88df8a8cd7e652e567ff097b7e9df0022c8695cc4" +dependencies = [ + "backtrace", + "eyre", + "indenter", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bf90406 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = ["odorobo-agent"] + +[workspace.dependencies] +stable-eyre = "0.2.2" +tracing = "0.1" +tracing-subscriber = "0.3" +cloud-hypervisor-client = "0.3" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 46252d6..f8c20a9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ -# odorobo +# Odorobo 🤖💃 + +> [Umicha - Odorobo](https://youtu.be/D_UC0WJmLnc) + Stack Virtualization Platform - Powered by Cloud Hypervisor + +## Usage + +TODO \ No newline at end of file diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml new file mode 100644 index 0000000..ddb1f9f --- /dev/null +++ b/odorobo-agent/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "odorobo-agent" +version = "0.1.0" +edition = "2024" + + +[dependencies] +stable-eyre = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs new file mode 100644 index 0000000..cb12894 --- /dev/null +++ b/odorobo-agent/src/main.rs @@ -0,0 +1,11 @@ +mod state; +use stable_eyre::Result; +use std::fs::{self}; + +fn main() -> Result<()> { + stable_eyre::install()?; + + println!("Hello, world!"); + + Ok(()) +} diff --git a/odorobo-agent/src/state.rs b/odorobo-agent/src/state.rs new file mode 100644 index 0000000..04457c3 --- /dev/null +++ b/odorobo-agent/src/state.rs @@ -0,0 +1,56 @@ +//! Temporary state management for the agent. +//! in-memory state inside /run is not persisted across reboots, so we can use it to store runtime state of the agent and VMs like currently running +//! VMs, and their instances, etc. +//! +//! Persistent stuff should be stored in the database to reconcile from + +use stable_eyre::Result; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +const RUNTIME_VMS_DIR: &str = "/run/odorobo/vms"; + +// do some kind of API here, call home to gateway/orchestrator +// then create systemd services of `odorobo-ch@.service` for each VM + +pub struct VMInstance { + pub id: String, // ulid + pub ch_socket_path: PathBuf, +} + +impl VMInstance { + pub fn info() -> Result { + // call info from the socket path + // ch-remote --api-socket {self.ch_socket_path} info + todo!() + } + + /// Call a custom API path with an optional body on the socket + pub fn call(path: &str, body: Option<&str>) -> Result<()> { + // call the api socket with the path and value + // ch-remote --api-socket {self.ch_socket_path} call {path} {value} + todo!() + } +} + +pub fn init() -> Result<()> { + if !Path::new(RUNTIME_VMS_DIR).exists() { + fs::create_dir_all(RUNTIME_VMS_DIR)?; + } + Ok(()) +} + +pub fn list_vms() -> Result> { + Ok(fs::read_dir(RUNTIME_VMS_DIR)? + .filter_map(|entry| { + entry.ok().and_then(|e| { + if e.file_type().ok()?.is_dir() { + Some(e.file_name().to_string_lossy().to_string()) + } else { + None + } + }) + }) + .collect::>()) +} diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service new file mode 100644 index 0000000..8d0d60d --- /dev/null +++ b/systemd/odorobo-ch@.service @@ -0,0 +1,76 @@ +[Unit] +Description=Odorobo Cloud Hypervisor VM (%i) +Documentation=https://www.cloudhypervisor.org/docs/ +After=network-online.target odorobo-agent.service +Wants=network-online.target +Requires=odorobo-agent.service + +# If this unit fails, don't take down other VMs +OnFailure=odorobo-ch-failed@%i.service + +[Service] +Type=simple +User=odorobo +Group=odorobo + +RuntimeDirectory=odorobo odorobo/vms odorobo/vms/%i +RuntimeDirectoryMode=0750 +LogsDirectory=odorobo +LogsDirectoryMode=0750 +ConfigurationDirectory=odorobo/vms/%i +ConfigurationDirectoryMode=0750 + +ExecStartPre=/usr/local/libexec/odorobo-preflight %i +# the actual VM process +ExecStart=/usr/bin/cloud-hypervisor \ + --api-socket /run/odorobo/vms/%i/ch.sock \ + --log-file /var/log/odorobo/%i.log \ + --serial tty \ + --console off + +ExecStartPost=/usr/local/libexec/odorobo-postflight %i +ExecStop=/usr/local/libexec/odorobo-stop %i + +Restart=on-failure +RestartSec=3s +RestartPreventExitStatus=0 +TimeoutStopSec=30s + +CPUAccounting=yes +MemoryAccounting=yes +IOAccounting=yes +TasksAccounting=yes + +# Security hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictNamespaces=yes +LockPersonality=yes +# CH needs JIT/executable memory +MemoryDenyWriteExecute=no +# vCPU threads need realtime scheduling +RestrictRealtime=no +AmbientCapabilities=CAP_NET_ADMIN + +# Paths CH legitimately needs to write +ReadWritePaths=/run/odorobo /var/log/odorobo /etc/odorobo /var/lib/odorobo + +# KVM device access +DeviceAllow=/dev/kvm rw +DeviceAllow=/dev/net/tun rw +DeviceAllow=/dev/vhost-net rw +DeviceAllow=/dev/urandom r + +# storage +DeviceAllow=/dev/nbd* rw +DeviceAllow=/dev/rbd* rw +DeviceAllow=/dev/sd* rw +DeviceAllow=/dev/nvme* rw +DeviceAllow=/dev/disk/by-id/* rw + +[Install] +WantedBy=multi-user.target diff --git a/systemd/scripts/odorobo-cleanup b/systemd/scripts/odorobo-cleanup new file mode 100644 index 0000000..bda8065 --- /dev/null +++ b/systemd/scripts/odorobo-cleanup @@ -0,0 +1,7 @@ +#!/bin/sh + +VMID=$1 +if [ -z "$VMID" ]; then + echo "Usage: $0 " + exit 1 +fi diff --git a/systemd/scripts/odorobo-postflight b/systemd/scripts/odorobo-postflight new file mode 100644 index 0000000..1251db0 --- /dev/null +++ b/systemd/scripts/odorobo-postflight @@ -0,0 +1,7 @@ +#!/bin/sh + +VMID=$1 +if [ -z "$VMID" ]; then + echo "Usage: $0 " + exit 1 +fi \ No newline at end of file diff --git a/systemd/scripts/odorobo-preflight b/systemd/scripts/odorobo-preflight new file mode 100644 index 0000000..1251db0 --- /dev/null +++ b/systemd/scripts/odorobo-preflight @@ -0,0 +1,7 @@ +#!/bin/sh + +VMID=$1 +if [ -z "$VMID" ]; then + echo "Usage: $0 " + exit 1 +fi \ No newline at end of file From f5181d368f9912fa91fa63ce12a7ebbc4d293ef1 Mon Sep 17 00:00:00 2001 From: Pornpipat Popum Date: Wed, 25 Mar 2026 15:10:12 +0700 Subject: [PATCH 003/189] Add GitHub Actions workflow for Rust project --- .github/workflows/rust.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..9fd45e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose From 5fe2e7dd73ea3963b18435f40032dc74c9d2cdef Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 10:18:04 +0700 Subject: [PATCH 004/189] implement vmm code, stub test path (no API yet) --- .gitignore | 2 + Cargo.lock | 1389 ++++++++++++++++++++++++++++++++---- Cargo.toml | 3 +- README.md | 17 + odorobo-agent/Cargo.toml | 9 +- odorobo-agent/src/main.rs | 44 +- odorobo-agent/src/state.rs | 530 +++++++++++++- 7 files changed, 1839 insertions(+), 155 deletions(-) diff --git a/.gitignore b/.gitignore index ad67955..66900dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # will have compiled files and executables debug target +# ignore dev scratch files +dev # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 6936fe7..f2e9364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "backtrace" version = "0.3.71" @@ -32,6 +44,40 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5032d51da2741729bfdaeb2664d9b8c6d9fd1e2b90715c660b6def36628499c2" +dependencies = [ + "byteorder", + "safemem", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.57" @@ -48,6 +94,54 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cloud-hypervisor-client" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a135a9339a5ad2775b8843dfad19cd5057e9d8092a3c09b523c5030fcc1348ee" +dependencies = [ + "base64", + "futures", + "http 0.2.12", + "http-body-util", + "hyper", + "hyper-util", + "hyperlocal", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "url", + "uuid", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "eyre" version = "0.6.12" @@ -65,245 +159,1300 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "gimli" -version = "0.28.1" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "indenter" -version = "0.3.4" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "lazy_static" -version = "1.5.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "libc" -version = "0.2.183" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "log" -version = "0.4.29" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "miniz_oxide" -version = "0.7.4" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ - "adler", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "windows-sys", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "object" -version = "0.32.2" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "odorobo-agent" -version = "0.1.0" +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "stable-eyre", + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", "tracing", - "tracing-subscriber", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "unicode-ident", + "bytes", + "fnv", + "itoa", ] [[package]] -name = "quote" -version = "1.0.45" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "proc-macro2", + "bytes", + "itoa", ] [[package]] -name = "rustc-demangle" -version = "0.1.27" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "lazy_static", + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "smallvec" -version = "1.15.1" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "stable-eyre" -version = "0.2.2" +name = "hyper" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556fec8c2da34c70b75f16d88df8a8cd7e652e567ff097b7e9df0022c8695cc4" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ - "backtrace", - "eyre", - "indenter", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", ] [[package]] -name = "syn" -version = "2.0.117" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "thread_local" -version = "1.1.9" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ - "cfg-if", + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ - "proc-macro2", - "quote", - "syn", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "icu_normalizer" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "once_cell", - "valuable", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "icu_normalizer_data" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "log", - "once_cell", - "tracing-core", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "tracing-subscriber" -version = "0.3.23" +name = "icu_properties_data" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ - "nu-ansi-term", - "sharded-slab", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", "smallvec", - "thread_local", - "tracing-core", - "tracing-log", + "utf8_iter", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "idna_adapter" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] -name = "valuable" -version = "0.1.1" +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] -name = "windows-link" -version = "0.2.1" +name = "indexmap" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ - "windows-link", + "once_cell", + "wasm-bindgen", ] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "odorobo-agent" +version = "0.1.0" +dependencies = [ + "cloud-hypervisor-client", + "http-body-util", + "hyper", + "hyper-util", + "hyperlocal", + "serde_json", + "stable-eyre", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safemem" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27a8b19b835f7aea908818e871f5cc3a5a186550c30773be987e155e8163d8f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable-eyre" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556fec8c2da34c70b75f16d88df8a8cd7e652e567ff097b7e9df0022c8695cc4" +dependencies = [ + "backtrace", + "eyre", + "indenter", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bf90406..fbd771b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = ["odorobo-agent"] stable-eyre = "0.2.2" tracing = "0.1" tracing-subscriber = "0.3" -cloud-hypervisor-client = "0.3" \ No newline at end of file +cloud-hypervisor-client = "0.3" +serde_json = "1.0.149" \ No newline at end of file diff --git a/README.md b/README.md index f8c20a9..b115447 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,23 @@ Stack Virtualization Platform - Powered by Cloud Hypervisor +Odorobo^(robot dance)^ is a hypervisor platform built on top of +[Cloud Hypervisor](https://www.cloudhypervisor.org/), a lightweight +VMM built on rust-vmm, with a focus on providing lightweight, +stateful, and portable virtual machines. + +VM configuration is persisted by the gateway and portable across nodes; +disk images can be backed by distributed storage for full portability, or kept node-local for simplicity. + +## Components + +- **Agent**: A lightweight agent that handles VM lifecycle management, including creation, deletion, and state management. +- **Gateway**: A central controller that orchestrates the deployment and management of VMs, reconciling and handling migration across nodes (unimplemented). +- **CLI**: Debugging and management command-line interface for interacting with the platform, not intended for end-users but useful for developers and operators (unimplemented). + +The Gateway orchestrates across nodes; +Agents run on each node and manage Cloud Hypervisor instances directly via systemd. + ## Usage TODO \ No newline at end of file diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index ddb1f9f..1a2bf23 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -2,9 +2,16 @@ name = "odorobo-agent" version = "0.1.0" edition = "2024" - +license = "AGPL-3.0-or-later" [dependencies] stable-eyre = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +cloud-hypervisor-client = { workspace = true } +http-body-util = "0.1.3" +hyper = { version = "1.8.1", features = ["full"] } +hyper-util = { version = "0.1.20", features = ["client", "client-legacy", "http1", "http2"] } +serde_json = { workspace = true } +hyperlocal = "0.9.1" +tokio = { version = "1.50.0", features = ["full"] } diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index cb12894..1e21a41 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,11 +1,47 @@ + mod state; +use cloud_hypervisor_client::{ + apis::DefaultApi, + models::{CpusConfig, VmConfig}, +}; use stable_eyre::Result; -use std::fs::{self}; - -fn main() -> Result<()> { +use state::VMStateManager; +#[tokio::main] +async fn main() -> Result<()> { stable_eyre::install()?; + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .init(); + + state::init()?; + tracing::info!("Starting odorobo-agent..."); + + // println!("Hello, world!"); + + let testvm = state::VMInstance::create_instance("testvm", None).await?; + + let list = state::VMInstance::list()?; + + tracing::info!("VM Instances: {list:#?}"); + + // let info = testvm + // .apply_config(&VmConfig { + // cpus: Some(CpusConfig { + // boot_vcpus: 2, + // max_vcpus: 4, + // topology: None, + // ..Default::default() + // }), + // ..Default::default() + // }) + // .await?; + // tracing::info!(?info); + + let vminfo = testvm.info().await?; + + testvm.vmm_ping_get().await?; - println!("Hello, world!"); + tracing::info!(?vminfo); Ok(()) } diff --git a/odorobo-agent/src/state.rs b/odorobo-agent/src/state.rs index 04457c3..3363e54 100644 --- a/odorobo-agent/src/state.rs +++ b/odorobo-agent/src/state.rs @@ -4,53 +4,525 @@ //! //! Persistent stuff should be stored in the database to reconcile from -use stable_eyre::Result; -use std::fs::{self, File}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use cloud_hypervisor_client::{ + SocketBasedApiClient, + apis::{ApiError, DefaultApi, Error as ChApiClientError}, + models::{self, VmConfig, VmInfo, vm_info::State}, +}; +use http_body_util::BodyExt; +use hyper::{Method, Request, header::CONTENT_TYPE}; +use hyperlocal::UnixClientExt; +use serde_json::Value; +use stable_eyre::{ + Result, + eyre::{Context, eyre}, +}; +use std::{ + env, + fs::{self}, + future::Future, + path::{Path, PathBuf}, + pin::Pin, +}; +use tracing::{info, trace, warn}; -const RUNTIME_VMS_DIR: &str = "/run/odorobo/vms"; +const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; +const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; +pub trait VMStateManager: DefaultApi { + fn vm_id(&self) -> &str; + fn ch_socket_path(&self) -> &Path; + fn config_path(&self) -> PathBuf; + + /// Get the current VM info for this VM instance, if it exists. + async fn info(&self) -> Result { + self.vm_info_get().await.wrap_err(eyre!( + "Failed to get VM info for VM with id {}", + self.vm_id() + )) + } + + /// Fetch the running VM's config from the CH info API. + async fn runtime_config(&self) -> Result { + let info = self.info().await?; + Ok(info.config) + } + + /// Fetch the desired VM config from disk. + fn file_config(&self) -> Result { + let config_data = fs::read_to_string(self.config_path()).wrap_err(eyre!( + "Failed to read config file for VM with id {}", + self.vm_id() + ))?; + + let config: VmConfig = serde_json::from_str(&config_data).wrap_err(eyre!( + "Failed to parse config JSON for VM with id {}", + self.vm_id() + ))?; + + Ok(config) + } + + /// Save the desired VM config to disk. + fn save_config(&self, config: &VmConfig) -> Result<()> { + let config_data = + serde_json::to_string_pretty(config).wrap_err("Failed to serialize config to JSON")?; + + fs::write(self.config_path(), config_data).wrap_err(eyre!( + "Failed to write config file for VM with id {}", + self.vm_id() + ))?; + + Ok(()) + } + + /// Apply/reconcile the given config to the running VM. + async fn apply_config(&self, config: &VmConfig) -> Result<()> { + self.save_config(config)?; + + let vm_state = match self.info().await { + Ok(info) => { + let vm_state = info.state; + info!( + vm_id = self.vm_id(), + ?vm_state, + "Applying config for VM, current state of VM is {vm_state:?}", + ); + + vm_state + } + Err(err) => { + if let Some(api_error) = find_api_error(&err) { + info!(vm_id = self.vm_id(), ?api_error, "API error details"); + } + + if is_vm_not_created_error(&err) { + info!( + vm_id = self.vm_id(), + "VM is not created yet, creating it from desired config" + ); + + self.create_vm(config.clone()) + .await + .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; + + info!(vm_id = self.vm_id(), "Successfully applied config for VM"); + return Ok(()); + } + + return Err(err).wrap_err(eyre!( + "Failed to inspect current state for VM with id {}", + self.vm_id() + )); + } + }; + + match vm_state { + State::Shutdown => { + self.create_vm(config.clone()) + .await + .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; + } + State::Created => { + info!( + vm_id = self.vm_id(), + "VM is in Created state, recreating VM to reconcile" + ); + self.delete_vm().await.wrap_err(eyre!( + "Failed to delete existing VM with id {} to apply new config", + self.vm_id() + ))?; + + self.create_vm(config.clone()).await.wrap_err(eyre!( + "Failed to create VM with id {} after deleting existing VM", + self.vm_id() + ))?; + } + State::Running | State::Paused => { + self.shutdown_vm() + .await + .wrap_err(eyre!("Failed to shutdown VM with id {}", self.vm_id()))?; + + self.create_vm(config.clone()) + .await + .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; + } + } + + info!(vm_id = self.vm_id(), "Successfully applied config for VM"); + + Ok(()) + } + + /// Call a custom API path with an optional JSON body on the socket. + async fn call(&self, path: &str, body: Option<&Value>) -> Result<()> { + let normalized_path = normalize_api_path(path)?; + let method = default_method_for_path(&normalized_path, body); + + call_with_method(self.ch_socket_path(), method, &normalized_path, body) + .await + .wrap_err(eyre!( + "Failed to call custom CH API path {} for VM with id {}", + normalized_path, + self.vm_id() + )) + } +} // do some kind of API here, call home to gateway/orchestrator // then create systemd services of `odorobo-ch@.service` for each VM - +#[derive(Debug, Clone)] pub struct VMInstance { pub id: String, // ulid pub ch_socket_path: PathBuf, } impl VMInstance { - pub fn info() -> Result { - // call info from the socket path - // ch-remote --api-socket {self.ch_socket_path} info - todo!() + const CONFIG_FILE_NAME: &'static str = "config.json"; + const SOCKET_FILE_NAME: &'static str = "ch.sock"; + const VMS_DIR_NAME: &'static str = "vms"; + + pub fn new(id: &str, ch_socket_path: PathBuf) -> Self { + Self { + id: id.to_string(), + ch_socket_path, + } + } + + /// Create a new instance for a VM given its ID and an optional config, optionally also applying the config if provided. + #[tracing::instrument(skip(config))] // don't log the config since it can be big and contain sensitive info + pub async fn create_instance(id: &str, config: Option) -> Result { + // Call whatever system orchestrator to spawn CH instance + + // For now we will just do this manually, see `systemd/odorobo-ch@.service` for the expected setup of the CH instance + // ```sh + // systemctl start odorobo-ch@.service + // ``` + + // Now let's instantiate the VMInstance struct for this VM + + info!(vm_id = id, socket_path = ?Self::configured_runtime_root().join(Self::VMS_DIR_NAME).join(id).join(Self::SOCKET_FILE_NAME), "Creating VMInstance for new VM"); + let instance = Self::new( + id, + Self::configured_runtime_root() + .join(Self::VMS_DIR_NAME) + .join(id) + .join(Self::SOCKET_FILE_NAME), + ); + + const MAX_ATTEMPTS: u32 = 31; + let mut socket_available = false; + for attempt in 0..MAX_ATTEMPTS { + trace!( + vm_id = id, + attempt, "Pinging CH socket waiting to become available..." + ); + if instance.vmm_ping_get().await.is_ok() { + trace!(vm_id = id, "Socket available, VMM is up!"); + socket_available = true; + break; + } else { + trace!(vm_id = id, attempt, "Socket not available yet, retrying..."); + } + if attempt < MAX_ATTEMPTS - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + } + if !socket_available { + return Err(eyre!( + "Failed to ping CH socket for VM with id {id} after 31 attempts, did the process orchestrator start the CH instance correctly?" + )); + } + Ok(instance) + } + + /// Destroy the CH instance for this VM. + /// This should be called when the VM is shut down and we no longer need to keep runtime state around, freeing up + /// host resources. + /// + /// Remember to also ask the process orchestrator to stop the CH instance when we're done! + pub async fn destroy_instance(&self) -> Result<()> { + // ask CH to shut down the VM if it's still running, we want to do this gracefully if possible to avoid data loss + if let Ok(info) = self.info().await { + if info.state == State::Running || info.state == State::Paused { + info!( + vm_id = self.vm_id(), + "Shutting down running VM before destroying instance" + ); + self.shutdown_vm().await.wrap_err(eyre!( + "Failed to shutdown VM with id {} before destroying instance", + self.vm_id() + ))?; + } + } + + // now ask CH to shut itself down + self.shutdown_vmm().await.wrap_err(eyre!( + "Failed to shutdown CH VMM instance for VM with id {}", + self.vm_id() + ))?; + + // Call whatever system orchestrator to destroy CH instance and clean up the deployment + + // For now we will just do this manually, see `systemd/odorobo-ch@.service` for the expected setup of the CH instance + // ```sh + // systemctl stop odorobo-ch@.service + // ``` + + // Now purge runtime state for this VM since we no longer need it + let purge_result = self.purge_instance_data(); + if let Err(err) = purge_result { + warn!( + vm_id = self.vm_id(), + ?err, + "Failed to purge runtime data for VM with id {}, manual cleanup may be required", + self.vm_id() + ); + } + + Ok(()) + } + + pub fn purge_instance_data(&self) -> Result<()> { + let runtime_dir = self.runtime_dir(); + if runtime_dir.exists() { + fs::remove_dir_all(runtime_dir).wrap_err(eyre!( + "Failed to remove runtime directory for VM with id {}", + self.vm_id() + ))?; + } + Ok(()) + } + + pub fn configured_runtime_root() -> PathBuf { + env::var_os(RUNTIME_ROOT_ENV_VAR) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_ROOT_DIR)) + } + + /// Return the path to the runtime root directory for the agent, which is where the agent stores runtime state for VMs. + pub fn runtime_root() -> PathBuf { + Self::configured_runtime_root().join(Self::VMS_DIR_NAME) + } + + /// Return the path to the runtime directory for a given VM instance ID, which is where the agent stores runtime state for that VM instance. + pub fn runtime_dir_for(id: &str) -> PathBuf { + Self::runtime_root().join(id) } - /// Call a custom API path with an optional body on the socket - pub fn call(path: &str, body: Option<&str>) -> Result<()> { - // call the api socket with the path and value - // ch-remote --api-socket {self.ch_socket_path} call {path} {value} - todo!() + /// Return the path to the runtime directory for this VM instance, which is where the agent stores runtime state for this VM instance. + pub fn runtime_dir(&self) -> PathBuf { + Self::runtime_dir_for(&self.id) + } + + /// Return the path to the CH API socket for this VM instance, if it exists. + pub fn conn(&self) -> SocketBasedApiClient { + cloud_hypervisor_client::socket_based_api_client(self.ch_socket_path.clone()) + } + + /// List running VM instances + /// + /// Looks up subdirectories in the runtime root directory, checking if there's a running CH + /// socket in each subdirectory, and returns a list of VMInstance for each valid one it finds. + pub fn list() -> Result> { + Ok(fs::read_dir(Self::runtime_root())? + .filter_map(|entry| { + entry.ok().and_then(|entry| { + if !entry.file_type().ok()?.is_dir() { + return None; + } + + let id = entry.file_name().to_string_lossy().to_string(); + let ch_socket_path = Self::runtime_dir_for(&id).join(Self::SOCKET_FILE_NAME); + + Some(Self::new(&id, ch_socket_path)) + }) + }) + .collect()) } } -pub fn init() -> Result<()> { - if !Path::new(RUNTIME_VMS_DIR).exists() { - fs::create_dir_all(RUNTIME_VMS_DIR)?; +macro_rules! delegate_default_api_no_args { + ($name:ident -> $output:ty) => { + fn $name( + &self, + ) -> Pin> + Send>> { + let client = self.conn(); + Box::pin(async move { client.$name().await }) + } + }; +} + +macro_rules! delegate_default_api_one_arg { + ($name:ident, $arg:ident : $arg_ty:ty, $output:ty) => { + fn $name( + &self, + $arg: $arg_ty, + ) -> Pin> + Send>> { + let client = self.conn(); + Box::pin(async move { client.$name($arg).await }) + } + }; +} + +// Delegate API methods to the helper +impl DefaultApi for VMInstance { + delegate_default_api_no_args!(boot_vm -> ()); + delegate_default_api_one_arg!(create_vm, vm_config: models::VmConfig, ()); + delegate_default_api_no_args!(delete_vm -> ()); + delegate_default_api_no_args!(pause_vm -> ()); + delegate_default_api_no_args!(power_button_vm -> ()); + delegate_default_api_no_args!(reboot_vm -> ()); + delegate_default_api_no_args!(resume_vm -> ()); + delegate_default_api_no_args!(shutdown_vm -> ()); + delegate_default_api_no_args!(shutdown_vmm -> ()); + delegate_default_api_one_arg!(vm_add_device_put, device_config: models::DeviceConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_disk_put, disk_config: models::DiskConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_fs_put, fs_config: models::FsConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_net_put, net_config: models::NetConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_pmem_put, pmem_config: models::PmemConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_user_device_put, vm_add_user_device: models::VmAddUserDevice, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_vdpa_put, vdpa_config: models::VdpaConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_add_vsock_put, vsock_config: models::VsockConfig, models::PciDeviceInfo); + delegate_default_api_one_arg!(vm_coredump_put, vm_coredump_data: models::VmCoredumpData, ()); + delegate_default_api_no_args!(vm_counters_get -> std::collections::HashMap>); + delegate_default_api_no_args!(vm_info_get -> models::VmInfo); + delegate_default_api_one_arg!(vm_receive_migration_put, receive_migration_data: models::ReceiveMigrationData, ()); + delegate_default_api_one_arg!(vm_remove_device_put, vm_remove_device: models::VmRemoveDevice, ()); + delegate_default_api_one_arg!(vm_resize_put, vm_resize: models::VmResize, ()); + delegate_default_api_one_arg!(vm_resize_zone_put, vm_resize_zone: models::VmResizeZone, ()); + delegate_default_api_one_arg!(vm_restore_put, restore_config: models::RestoreConfig, ()); + delegate_default_api_one_arg!(vm_send_migration_put, send_migration_data: models::SendMigrationData, ()); + delegate_default_api_one_arg!(vm_snapshot_put, vm_snapshot_config: models::VmSnapshotConfig, ()); + delegate_default_api_no_args!(vmm_nmi_put -> ()); + delegate_default_api_no_args!(vmm_ping_get -> models::VmmPingResponse); +} + +impl VMStateManager for VMInstance { + fn vm_id(&self) -> &str { + &self.id + } + + fn ch_socket_path(&self) -> &Path { + &self.ch_socket_path + } + + fn config_path(&self) -> PathBuf { + self.runtime_dir().join(Self::CONFIG_FILE_NAME) } - Ok(()) } -pub fn list_vms() -> Result> { - Ok(fs::read_dir(RUNTIME_VMS_DIR)? - .filter_map(|entry| { - entry.ok().and_then(|e| { - if e.file_type().ok()?.is_dir() { - Some(e.file_name().to_string_lossy().to_string()) - } else { - None - } +fn find_api_error(err: &stable_eyre::Report) -> Option<&ApiError> { + err.chain().find_map(|cause| { + cause + .downcast_ref::() + .and_then(|api_error| match api_error { + ChApiClientError::Api(api_error) => Some(api_error), + _ => None, }) + }) +} + +fn is_vm_not_created_error(err: &stable_eyre::Report) -> bool { + let Some(api_error) = find_api_error(err) else { + return false; + }; + + if api_error.body.contains("VM is not created") { + return true; + } + + serde_json::from_str::>(&api_error.body) + .map(|messages| { + messages + .iter() + .any(|message| message == "VM is not created") }) - .collect::>()) + .unwrap_or(false) +} + +async fn call_with_method( + socket_path: &Path, + method: Method, + path: &str, + body: Option<&Value>, +) -> Result<()> { + let api_path = format!("/api/v1{path}"); + let uri: hyper::Uri = hyperlocal::Uri::new(socket_path, &api_path).into(); + let client = hyper_util::client::legacy::Client::unix(); + let request_body = body.map(Value::to_string).unwrap_or_default(); + + let mut request = Request::builder().method(method).uri(uri); + + if body.is_some() { + request = request.header(CONTENT_TYPE, "application/json"); + } + + let request = request + .body(request_body) + .wrap_err("Failed to build CH API request")?; + + let response = client + .request(request) + .await + .wrap_err("Failed to send CH API request over unix socket")?; + + let status = response.status(); + let response_body = response + .into_body() + .collect() + .await + .wrap_err("Failed to read CH API response body")? + .to_bytes(); + + if !status.is_success() { + let response_body = String::from_utf8_lossy(&response_body); + return Err(eyre!( + "CH API returned {} for {}: {}", + status, + path, + response_body + )); + } + + Ok(()) +} + +fn normalize_api_path(path: &str) -> Result { + let path = path.trim(); + + if path.is_empty() { + return Err(eyre!("CH API path cannot be empty")); + } + + let path = path + .strip_prefix("/api/v1") + .or_else(|| path.strip_prefix("api/v1")) + .unwrap_or(path) + .trim_start_matches('/'); + + if path.is_empty() { + return Err(eyre!("CH API path cannot point to the API root only")); + } + + Ok(format!("/{path}")) +} + +fn default_method_for_path(path: &str, body: Option<&Value>) -> Method { + if body.is_some() { + return Method::PUT; + } + + if path.ends_with(".info") || path.ends_with(".counters") || path.ends_with(".ping") { + Method::GET + } else { + Method::PUT + } +} +#[tracing::instrument] +pub fn init() -> Result<()> { + info!("Initializing state manager"); + if !VMInstance::runtime_root().exists() { + fs::create_dir_all(VMInstance::runtime_root())?; + } + Ok(()) } From 2ba3bbffc5496abd4381ce389f71575914044666 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 10:19:03 +0700 Subject: [PATCH 005/189] superscript in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b115447..b799339 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Stack Virtualization Platform - Powered by Cloud Hypervisor -Odorobo^(robot dance)^ is a hypervisor platform built on top of +Odorobo(robot dance) is a hypervisor platform built on top of [Cloud Hypervisor](https://www.cloudhypervisor.org/), a lightweight VMM built on rust-vmm, with a focus on providing lightweight, stateful, and portable virtual machines. From fcbeceaf7717dd94d4c5cfaf32f719e8080e2505 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 13:53:01 +0700 Subject: [PATCH 006/189] refactor state module into submodules for readability --- odorobo-agent/src/main.rs | 35 +- odorobo-agent/src/state.rs | 528 --------------------------- odorobo-agent/src/state/api.rs | 77 ++++ odorobo-agent/src/state/instance.rs | 259 +++++++++++++ odorobo-agent/src/state/mod.rs | 12 + odorobo-agent/src/state/transform.rs | 56 +++ systemd/odorobo-ch@.service | 5 +- 7 files changed, 409 insertions(+), 563 deletions(-) delete mode 100644 odorobo-agent/src/state.rs create mode 100644 odorobo-agent/src/state/api.rs create mode 100644 odorobo-agent/src/state/instance.rs create mode 100644 odorobo-agent/src/state/mod.rs create mode 100644 odorobo-agent/src/state/transform.rs diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 1e21a41..7e9442d 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,11 +1,7 @@ - mod state; -use cloud_hypervisor_client::{ - apis::DefaultApi, - models::{CpusConfig, VmConfig}, -}; + use stable_eyre::Result; -use state::VMStateManager; +use state::VMInstance; #[tokio::main] async fn main() -> Result<()> { stable_eyre::install()?; @@ -13,35 +9,10 @@ async fn main() -> Result<()> { .with_max_level(tracing::Level::TRACE) .init(); - state::init()?; tracing::info!("Starting odorobo-agent..."); - // println!("Hello, world!"); - - let testvm = state::VMInstance::create_instance("testvm", None).await?; - - let list = state::VMInstance::list()?; - + let list = VMInstance::list()?; tracing::info!("VM Instances: {list:#?}"); - // let info = testvm - // .apply_config(&VmConfig { - // cpus: Some(CpusConfig { - // boot_vcpus: 2, - // max_vcpus: 4, - // topology: None, - // ..Default::default() - // }), - // ..Default::default() - // }) - // .await?; - // tracing::info!(?info); - - let vminfo = testvm.info().await?; - - testvm.vmm_ping_get().await?; - - tracing::info!(?vminfo); - Ok(()) } diff --git a/odorobo-agent/src/state.rs b/odorobo-agent/src/state.rs deleted file mode 100644 index 3363e54..0000000 --- a/odorobo-agent/src/state.rs +++ /dev/null @@ -1,528 +0,0 @@ -//! Temporary state management for the agent. -//! in-memory state inside /run is not persisted across reboots, so we can use it to store runtime state of the agent and VMs like currently running -//! VMs, and their instances, etc. -//! -//! Persistent stuff should be stored in the database to reconcile from - -use cloud_hypervisor_client::{ - SocketBasedApiClient, - apis::{ApiError, DefaultApi, Error as ChApiClientError}, - models::{self, VmConfig, VmInfo, vm_info::State}, -}; -use http_body_util::BodyExt; -use hyper::{Method, Request, header::CONTENT_TYPE}; -use hyperlocal::UnixClientExt; -use serde_json::Value; -use stable_eyre::{ - Result, - eyre::{Context, eyre}, -}; -use std::{ - env, - fs::{self}, - future::Future, - path::{Path, PathBuf}, - pin::Pin, -}; -use tracing::{info, trace, warn}; - -const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; -const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; - -pub trait VMStateManager: DefaultApi { - fn vm_id(&self) -> &str; - fn ch_socket_path(&self) -> &Path; - fn config_path(&self) -> PathBuf; - - /// Get the current VM info for this VM instance, if it exists. - async fn info(&self) -> Result { - self.vm_info_get().await.wrap_err(eyre!( - "Failed to get VM info for VM with id {}", - self.vm_id() - )) - } - - /// Fetch the running VM's config from the CH info API. - async fn runtime_config(&self) -> Result { - let info = self.info().await?; - Ok(info.config) - } - - /// Fetch the desired VM config from disk. - fn file_config(&self) -> Result { - let config_data = fs::read_to_string(self.config_path()).wrap_err(eyre!( - "Failed to read config file for VM with id {}", - self.vm_id() - ))?; - - let config: VmConfig = serde_json::from_str(&config_data).wrap_err(eyre!( - "Failed to parse config JSON for VM with id {}", - self.vm_id() - ))?; - - Ok(config) - } - - /// Save the desired VM config to disk. - fn save_config(&self, config: &VmConfig) -> Result<()> { - let config_data = - serde_json::to_string_pretty(config).wrap_err("Failed to serialize config to JSON")?; - - fs::write(self.config_path(), config_data).wrap_err(eyre!( - "Failed to write config file for VM with id {}", - self.vm_id() - ))?; - - Ok(()) - } - - /// Apply/reconcile the given config to the running VM. - async fn apply_config(&self, config: &VmConfig) -> Result<()> { - self.save_config(config)?; - - let vm_state = match self.info().await { - Ok(info) => { - let vm_state = info.state; - info!( - vm_id = self.vm_id(), - ?vm_state, - "Applying config for VM, current state of VM is {vm_state:?}", - ); - - vm_state - } - Err(err) => { - if let Some(api_error) = find_api_error(&err) { - info!(vm_id = self.vm_id(), ?api_error, "API error details"); - } - - if is_vm_not_created_error(&err) { - info!( - vm_id = self.vm_id(), - "VM is not created yet, creating it from desired config" - ); - - self.create_vm(config.clone()) - .await - .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; - - info!(vm_id = self.vm_id(), "Successfully applied config for VM"); - return Ok(()); - } - - return Err(err).wrap_err(eyre!( - "Failed to inspect current state for VM with id {}", - self.vm_id() - )); - } - }; - - match vm_state { - State::Shutdown => { - self.create_vm(config.clone()) - .await - .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; - } - State::Created => { - info!( - vm_id = self.vm_id(), - "VM is in Created state, recreating VM to reconcile" - ); - self.delete_vm().await.wrap_err(eyre!( - "Failed to delete existing VM with id {} to apply new config", - self.vm_id() - ))?; - - self.create_vm(config.clone()).await.wrap_err(eyre!( - "Failed to create VM with id {} after deleting existing VM", - self.vm_id() - ))?; - } - State::Running | State::Paused => { - self.shutdown_vm() - .await - .wrap_err(eyre!("Failed to shutdown VM with id {}", self.vm_id()))?; - - self.create_vm(config.clone()) - .await - .wrap_err(eyre!("Failed to create VM with id {}", self.vm_id()))?; - } - } - - info!(vm_id = self.vm_id(), "Successfully applied config for VM"); - - Ok(()) - } - - /// Call a custom API path with an optional JSON body on the socket. - async fn call(&self, path: &str, body: Option<&Value>) -> Result<()> { - let normalized_path = normalize_api_path(path)?; - let method = default_method_for_path(&normalized_path, body); - - call_with_method(self.ch_socket_path(), method, &normalized_path, body) - .await - .wrap_err(eyre!( - "Failed to call custom CH API path {} for VM with id {}", - normalized_path, - self.vm_id() - )) - } -} -// do some kind of API here, call home to gateway/orchestrator -// then create systemd services of `odorobo-ch@.service` for each VM -#[derive(Debug, Clone)] -pub struct VMInstance { - pub id: String, // ulid - pub ch_socket_path: PathBuf, -} - -impl VMInstance { - const CONFIG_FILE_NAME: &'static str = "config.json"; - const SOCKET_FILE_NAME: &'static str = "ch.sock"; - const VMS_DIR_NAME: &'static str = "vms"; - - pub fn new(id: &str, ch_socket_path: PathBuf) -> Self { - Self { - id: id.to_string(), - ch_socket_path, - } - } - - /// Create a new instance for a VM given its ID and an optional config, optionally also applying the config if provided. - #[tracing::instrument(skip(config))] // don't log the config since it can be big and contain sensitive info - pub async fn create_instance(id: &str, config: Option) -> Result { - // Call whatever system orchestrator to spawn CH instance - - // For now we will just do this manually, see `systemd/odorobo-ch@.service` for the expected setup of the CH instance - // ```sh - // systemctl start odorobo-ch@.service - // ``` - - // Now let's instantiate the VMInstance struct for this VM - - info!(vm_id = id, socket_path = ?Self::configured_runtime_root().join(Self::VMS_DIR_NAME).join(id).join(Self::SOCKET_FILE_NAME), "Creating VMInstance for new VM"); - let instance = Self::new( - id, - Self::configured_runtime_root() - .join(Self::VMS_DIR_NAME) - .join(id) - .join(Self::SOCKET_FILE_NAME), - ); - - const MAX_ATTEMPTS: u32 = 31; - let mut socket_available = false; - for attempt in 0..MAX_ATTEMPTS { - trace!( - vm_id = id, - attempt, "Pinging CH socket waiting to become available..." - ); - if instance.vmm_ping_get().await.is_ok() { - trace!(vm_id = id, "Socket available, VMM is up!"); - socket_available = true; - break; - } else { - trace!(vm_id = id, attempt, "Socket not available yet, retrying..."); - } - if attempt < MAX_ATTEMPTS - 1 { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - } - } - if !socket_available { - return Err(eyre!( - "Failed to ping CH socket for VM with id {id} after 31 attempts, did the process orchestrator start the CH instance correctly?" - )); - } - Ok(instance) - } - - /// Destroy the CH instance for this VM. - /// This should be called when the VM is shut down and we no longer need to keep runtime state around, freeing up - /// host resources. - /// - /// Remember to also ask the process orchestrator to stop the CH instance when we're done! - pub async fn destroy_instance(&self) -> Result<()> { - // ask CH to shut down the VM if it's still running, we want to do this gracefully if possible to avoid data loss - if let Ok(info) = self.info().await { - if info.state == State::Running || info.state == State::Paused { - info!( - vm_id = self.vm_id(), - "Shutting down running VM before destroying instance" - ); - self.shutdown_vm().await.wrap_err(eyre!( - "Failed to shutdown VM with id {} before destroying instance", - self.vm_id() - ))?; - } - } - - // now ask CH to shut itself down - self.shutdown_vmm().await.wrap_err(eyre!( - "Failed to shutdown CH VMM instance for VM with id {}", - self.vm_id() - ))?; - - // Call whatever system orchestrator to destroy CH instance and clean up the deployment - - // For now we will just do this manually, see `systemd/odorobo-ch@.service` for the expected setup of the CH instance - // ```sh - // systemctl stop odorobo-ch@.service - // ``` - - // Now purge runtime state for this VM since we no longer need it - let purge_result = self.purge_instance_data(); - if let Err(err) = purge_result { - warn!( - vm_id = self.vm_id(), - ?err, - "Failed to purge runtime data for VM with id {}, manual cleanup may be required", - self.vm_id() - ); - } - - Ok(()) - } - - pub fn purge_instance_data(&self) -> Result<()> { - let runtime_dir = self.runtime_dir(); - if runtime_dir.exists() { - fs::remove_dir_all(runtime_dir).wrap_err(eyre!( - "Failed to remove runtime directory for VM with id {}", - self.vm_id() - ))?; - } - Ok(()) - } - - pub fn configured_runtime_root() -> PathBuf { - env::var_os(RUNTIME_ROOT_ENV_VAR) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_ROOT_DIR)) - } - - /// Return the path to the runtime root directory for the agent, which is where the agent stores runtime state for VMs. - pub fn runtime_root() -> PathBuf { - Self::configured_runtime_root().join(Self::VMS_DIR_NAME) - } - - /// Return the path to the runtime directory for a given VM instance ID, which is where the agent stores runtime state for that VM instance. - pub fn runtime_dir_for(id: &str) -> PathBuf { - Self::runtime_root().join(id) - } - - /// Return the path to the runtime directory for this VM instance, which is where the agent stores runtime state for this VM instance. - pub fn runtime_dir(&self) -> PathBuf { - Self::runtime_dir_for(&self.id) - } - - /// Return the path to the CH API socket for this VM instance, if it exists. - pub fn conn(&self) -> SocketBasedApiClient { - cloud_hypervisor_client::socket_based_api_client(self.ch_socket_path.clone()) - } - - /// List running VM instances - /// - /// Looks up subdirectories in the runtime root directory, checking if there's a running CH - /// socket in each subdirectory, and returns a list of VMInstance for each valid one it finds. - pub fn list() -> Result> { - Ok(fs::read_dir(Self::runtime_root())? - .filter_map(|entry| { - entry.ok().and_then(|entry| { - if !entry.file_type().ok()?.is_dir() { - return None; - } - - let id = entry.file_name().to_string_lossy().to_string(); - let ch_socket_path = Self::runtime_dir_for(&id).join(Self::SOCKET_FILE_NAME); - - Some(Self::new(&id, ch_socket_path)) - }) - }) - .collect()) - } -} - -macro_rules! delegate_default_api_no_args { - ($name:ident -> $output:ty) => { - fn $name( - &self, - ) -> Pin> + Send>> { - let client = self.conn(); - Box::pin(async move { client.$name().await }) - } - }; -} - -macro_rules! delegate_default_api_one_arg { - ($name:ident, $arg:ident : $arg_ty:ty, $output:ty) => { - fn $name( - &self, - $arg: $arg_ty, - ) -> Pin> + Send>> { - let client = self.conn(); - Box::pin(async move { client.$name($arg).await }) - } - }; -} - -// Delegate API methods to the helper -impl DefaultApi for VMInstance { - delegate_default_api_no_args!(boot_vm -> ()); - delegate_default_api_one_arg!(create_vm, vm_config: models::VmConfig, ()); - delegate_default_api_no_args!(delete_vm -> ()); - delegate_default_api_no_args!(pause_vm -> ()); - delegate_default_api_no_args!(power_button_vm -> ()); - delegate_default_api_no_args!(reboot_vm -> ()); - delegate_default_api_no_args!(resume_vm -> ()); - delegate_default_api_no_args!(shutdown_vm -> ()); - delegate_default_api_no_args!(shutdown_vmm -> ()); - delegate_default_api_one_arg!(vm_add_device_put, device_config: models::DeviceConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_disk_put, disk_config: models::DiskConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_fs_put, fs_config: models::FsConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_net_put, net_config: models::NetConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_pmem_put, pmem_config: models::PmemConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_user_device_put, vm_add_user_device: models::VmAddUserDevice, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_vdpa_put, vdpa_config: models::VdpaConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_add_vsock_put, vsock_config: models::VsockConfig, models::PciDeviceInfo); - delegate_default_api_one_arg!(vm_coredump_put, vm_coredump_data: models::VmCoredumpData, ()); - delegate_default_api_no_args!(vm_counters_get -> std::collections::HashMap>); - delegate_default_api_no_args!(vm_info_get -> models::VmInfo); - delegate_default_api_one_arg!(vm_receive_migration_put, receive_migration_data: models::ReceiveMigrationData, ()); - delegate_default_api_one_arg!(vm_remove_device_put, vm_remove_device: models::VmRemoveDevice, ()); - delegate_default_api_one_arg!(vm_resize_put, vm_resize: models::VmResize, ()); - delegate_default_api_one_arg!(vm_resize_zone_put, vm_resize_zone: models::VmResizeZone, ()); - delegate_default_api_one_arg!(vm_restore_put, restore_config: models::RestoreConfig, ()); - delegate_default_api_one_arg!(vm_send_migration_put, send_migration_data: models::SendMigrationData, ()); - delegate_default_api_one_arg!(vm_snapshot_put, vm_snapshot_config: models::VmSnapshotConfig, ()); - delegate_default_api_no_args!(vmm_nmi_put -> ()); - delegate_default_api_no_args!(vmm_ping_get -> models::VmmPingResponse); -} - -impl VMStateManager for VMInstance { - fn vm_id(&self) -> &str { - &self.id - } - - fn ch_socket_path(&self) -> &Path { - &self.ch_socket_path - } - - fn config_path(&self) -> PathBuf { - self.runtime_dir().join(Self::CONFIG_FILE_NAME) - } -} - -fn find_api_error(err: &stable_eyre::Report) -> Option<&ApiError> { - err.chain().find_map(|cause| { - cause - .downcast_ref::() - .and_then(|api_error| match api_error { - ChApiClientError::Api(api_error) => Some(api_error), - _ => None, - }) - }) -} - -fn is_vm_not_created_error(err: &stable_eyre::Report) -> bool { - let Some(api_error) = find_api_error(err) else { - return false; - }; - - if api_error.body.contains("VM is not created") { - return true; - } - - serde_json::from_str::>(&api_error.body) - .map(|messages| { - messages - .iter() - .any(|message| message == "VM is not created") - }) - .unwrap_or(false) -} - -async fn call_with_method( - socket_path: &Path, - method: Method, - path: &str, - body: Option<&Value>, -) -> Result<()> { - let api_path = format!("/api/v1{path}"); - let uri: hyper::Uri = hyperlocal::Uri::new(socket_path, &api_path).into(); - let client = hyper_util::client::legacy::Client::unix(); - let request_body = body.map(Value::to_string).unwrap_or_default(); - - let mut request = Request::builder().method(method).uri(uri); - - if body.is_some() { - request = request.header(CONTENT_TYPE, "application/json"); - } - - let request = request - .body(request_body) - .wrap_err("Failed to build CH API request")?; - - let response = client - .request(request) - .await - .wrap_err("Failed to send CH API request over unix socket")?; - - let status = response.status(); - let response_body = response - .into_body() - .collect() - .await - .wrap_err("Failed to read CH API response body")? - .to_bytes(); - - if !status.is_success() { - let response_body = String::from_utf8_lossy(&response_body); - return Err(eyre!( - "CH API returned {} for {}: {}", - status, - path, - response_body - )); - } - - Ok(()) -} - -fn normalize_api_path(path: &str) -> Result { - let path = path.trim(); - - if path.is_empty() { - return Err(eyre!("CH API path cannot be empty")); - } - - let path = path - .strip_prefix("/api/v1") - .or_else(|| path.strip_prefix("api/v1")) - .unwrap_or(path) - .trim_start_matches('/'); - - if path.is_empty() { - return Err(eyre!("CH API path cannot point to the API root only")); - } - - Ok(format!("/{path}")) -} - -fn default_method_for_path(path: &str, body: Option<&Value>) -> Method { - if body.is_some() { - return Method::PUT; - } - - if path.ends_with(".info") || path.ends_with(".counters") || path.ends_with(".ping") { - Method::GET - } else { - Method::PUT - } -} -#[tracing::instrument] -pub fn init() -> Result<()> { - info!("Initializing state manager"); - if !VMInstance::runtime_root().exists() { - fs::create_dir_all(VMInstance::runtime_root())?; - } - Ok(()) -} diff --git a/odorobo-agent/src/state/api.rs b/odorobo-agent/src/state/api.rs new file mode 100644 index 0000000..a4daa24 --- /dev/null +++ b/odorobo-agent/src/state/api.rs @@ -0,0 +1,77 @@ +use http_body_util::BodyExt; +use hyper::{Method, Request, header::CONTENT_TYPE}; +use hyperlocal::UnixClientExt; +use serde_json::Value; +use stable_eyre::{ + Result, + eyre::{WrapErr, eyre}, +}; +use std::path::Path; + +pub async fn call( + socket_path: &Path, + method: Method, + path: &str, + body: Option<&Value>, +) -> Result<()> { + let normalized_path = normalize_api_path(path)?; + let api_path = format!("/api/v1{normalized_path}"); + let uri: hyper::Uri = hyperlocal::Uri::new(socket_path, &api_path).into(); + let client = hyper_util::client::legacy::Client::unix(); + let request_body = body.map(Value::to_string).unwrap_or_default(); + + let mut request = Request::builder().method(method).uri(uri); + + if body.is_some() { + request = request.header(CONTENT_TYPE, "application/json"); + } + + let request = request + .body(request_body) + .wrap_err("Failed to build CH API request")?; + + let response = client + .request(request) + .await + .wrap_err("Failed to send CH API request over unix socket")?; + + let status = response.status(); + let response_body = response + .into_body() + .collect() + .await + .wrap_err("Failed to read CH API response body")? + .to_bytes(); + + if !status.is_success() { + let response_body = String::from_utf8_lossy(&response_body); + return Err(eyre!( + "CH API returned {} for {}: {}", + status, + path, + response_body + )); + } + + Ok(()) +} + +fn normalize_api_path(path: &str) -> Result { + let path = path.trim(); + + if path.is_empty() { + return Err(eyre!("CH API path cannot be empty")); + } + + let path = path + .strip_prefix("/api/v1") + .or_else(|| path.strip_prefix("api/v1")) + .unwrap_or(path) + .trim_start_matches('/'); + + if path.is_empty() { + return Err(eyre!("CH API path cannot point to the API root only")); + } + + Ok(format!("/{path}")) +} diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs new file mode 100644 index 0000000..3f7a958 --- /dev/null +++ b/odorobo-agent/src/state/instance.rs @@ -0,0 +1,259 @@ +use cloud_hypervisor_client::{ + SocketBasedApiClient, + apis::DefaultApi, + models::{self, VmInfo}, +}; +use hyper::Method; +use serde_json::Value; +use stable_eyre::{ + Result, + eyre::{Context, eyre}, +}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; +use tracing::{debug, info, warn}; + +use super::api::call; +use super::transform::apply_builtin_transforms; + +pub const CONFIG_FILE_NAME: &str = "config.json"; +const SOCKET_FILE_NAME: &str = "ch.sock"; +pub const VMS_DIR_NAME: &str = "vms"; + +const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; +const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; + +#[derive(Debug, Clone)] +pub struct VMInstance { + pub id: String, + pub ch_socket_path: PathBuf, +} + +impl VMInstance { + pub fn new(id: &str, ch_socket_path: PathBuf) -> Self { + Self { + id: id.to_string(), + ch_socket_path, + } + } + + pub fn runtime_root() -> PathBuf { + Self::configured_runtime_root().join(VMS_DIR_NAME) + } + + pub fn runtime_dir_for(id: &str) -> PathBuf { + Self::runtime_root().join(id) + } + + pub fn runtime_dir(&self) -> PathBuf { + Self::runtime_dir_for(&self.id) + } + + pub fn config_path(&self) -> PathBuf { + self.runtime_dir().join(CONFIG_FILE_NAME) + } + + pub fn configured_runtime_root() -> PathBuf { + env::var_os(RUNTIME_ROOT_ENV_VAR) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_ROOT_DIR)) + } + + fn conn(&self) -> SocketBasedApiClient { + cloud_hypervisor_client::socket_based_api_client(self.ch_socket_path.clone()) + } + + pub fn vm_id(&self) -> &str { + &self.id + } + + pub fn ch_socket_path(&self) -> &Path { + &self.ch_socket_path + } + + async fn info(&self) -> Result { + self.conn() + .vm_info_get() + .await + .wrap_err(eyre!("Failed to get VM info for {}", self.vm_id())) + } + + async fn shutdown(&self) -> Result<()> { + self.conn() + .shutdown_vm() + .await + .wrap_err(eyre!("Failed to shutdown VM {}", self.vm_id())) + } + + pub async fn console_socket(&self) -> Result { + let vminfo = self.info().await?; + if let Some(console) = vminfo.config.console { + match console.mode { + models::console_config::Mode::Pty => { + debug!( + vm_id = self.vm_id(), + "VM has PTY console configured, returning socket path" + ); + let socket_path = console.file.ok_or_else(|| { + eyre!("Console config is missing file path for PTY console") + })?; + Ok(socket_path.into()) + } + _ => Err(eyre!( + "Console is configured but is not a PTY console, unsupported console type" + )), + } + } else { + Err(eyre!("VM does not have a console configured nor supported")) + } + } + + /// Spawn a new CH process and create a VMInstance for it. + /// + /// Waits for the socket to become available (polls up to ~30 seconds). + /// The caller should handle config persistence and VM creation. + pub async fn spawn(id: &str) -> Result { + info!( + vm_id = id, + socket_path = ?Self::runtime_dir_for(id).join(SOCKET_FILE_NAME), + "Spawning CH process for new VM" + ); + + let instance = Self::new(id, Self::runtime_dir_for(id).join(SOCKET_FILE_NAME)); + + const MAX_ATTEMPTS: u32 = 31; + for attempt in 0..MAX_ATTEMPTS { + if instance.conn().vmm_ping_get().await.is_ok() { + debug!(vm_id = id, "CH socket available"); + return Ok(instance); + } + + if attempt < MAX_ATTEMPTS - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + } + } + + Err(eyre!( + "CH socket not available after {} attempts for VM {}", + MAX_ATTEMPTS, + id + )) + } + + /// Gracefully shutdown the VM and VMM, then clean up runtime state. + /// + /// Does NOT stop the CH deployment - that should be handled by the orchestrator. + pub async fn destroy(&self) -> Result<()> { + if let Ok(info) = self.info().await { + if matches!( + info.state, + models::vm_info::State::Running | models::vm_info::State::Paused + ) { + info!(vm_id = self.vm_id(), "Shutting down VM before destroy"); + self.shutdown().await?; + } + } + + self.conn() + .shutdown_vmm() + .await + .wrap_err(eyre!("Failed to shutdown VMM for {}", self.vm_id()))?; + + if let Err(err) = self.purge_instance_data() { + warn!( + vm_id = self.vm_id(), + ?err, + "Failed to purge runtime data, manual cleanup may be required" + ); + } + + Ok(()) + } + + /// Purge the runtime data for this VM instance. + /// + /// This removes the runtime directory and all its contents if it exists. + pub fn purge_instance_data(&self) -> Result<()> { + let runtime_dir = self.runtime_dir(); + if runtime_dir.exists() { + fs::remove_dir_all(runtime_dir).wrap_err(eyre!( + "Failed to remove runtime directory for {}", + self.vm_id() + ))?; + } + Ok(()) + } + + /// Load desired VM config from disk. + pub fn load_config(&self) -> Result { + let config_data = fs::read_to_string(self.config_path()) + .wrap_err(eyre!("Failed to read config file for {}", self.vm_id()))?; + + serde_json::from_str(&config_data) + .wrap_err(eyre!("Failed to parse config JSON for {}", self.vm_id())) + } + + /// Save desired VM config to disk. + pub fn save_config(&self, config: &models::VmConfig) -> Result<()> { + let config_data = + serde_json::to_string_pretty(config).wrap_err("Failed to serialize config to JSON")?; + + fs::write(self.config_path(), config_data) + .wrap_err(eyre!("Failed to write config file for {}", self.vm_id())) + } + + /// Create and boot a VM with the given config. + /// + /// Applies node-specific transforms, saves config to disk, then: + /// 1. Creates the VM via CH API + /// 2. Boots the VM + pub async fn create_and_boot(&self, config: models::VmConfig) -> Result<()> { + let mut config = config; + apply_builtin_transforms(&mut config).wrap_err("Failed to apply config transforms")?; + + self.save_config(&config)?; + self.conn() + .create_vm(config) + .await + .wrap_err(eyre!("Failed to create VM {}", self.vm_id()))?; + + self.conn() + .boot_vm() + .await + .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; + + info!(vm_id = self.vm_id(), "VM created and booted"); + Ok(()) + } + + /// Call a custom CH API path with an explicit HTTP method. + /// + /// For debugging: lets you hit any CH API endpoint directly. + pub async fn call(&self, method: Method, path: &str, body: Option<&Value>) -> Result<()> { + call(self.ch_socket_path(), method, path, body) + .await + .wrap_err(eyre!("Failed to call {} for {}", path, self.vm_id())) + } + + /// List running VM instances. + /// + /// Scans runtime root for directories with valid sockets. + pub fn list() -> Result> { + Ok(fs::read_dir(Self::runtime_root())? + .filter_map(|entry| { + entry.ok().and_then(|entry| { + if !entry.file_type().ok()?.is_dir() { + return None; + } + + let id = entry.file_name().to_string_lossy().to_string(); + let ch_socket_path = Self::runtime_dir_for(&id).join(SOCKET_FILE_NAME); + + Some(Self::new(&id, ch_socket_path)) + }) + }) + .collect()) + } +} diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs new file mode 100644 index 0000000..a83124e --- /dev/null +++ b/odorobo-agent/src/state/mod.rs @@ -0,0 +1,12 @@ +//! Temporary state management for the agent. +//! +//! Runtime state (in /run) is not persisted across reboots, so we use it for +//! ephemeral VM state like running instances. Persistent state goes in the database. + +mod api; +mod instance; +mod transform; + +pub use api::call; +pub use instance::{CONFIG_FILE_NAME, VMInstance, VMS_DIR_NAME}; +pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; diff --git a/odorobo-agent/src/state/transform.rs b/odorobo-agent/src/state/transform.rs new file mode 100644 index 0000000..2d414b7 --- /dev/null +++ b/odorobo-agent/src/state/transform.rs @@ -0,0 +1,56 @@ +use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use stable_eyre::Result; +use tracing::trace; + +pub trait ConfigTransform: Send + Sync { + fn transform(&self, config: &mut VmConfig) -> Result<()>; +} + +#[derive(Debug, Clone)] +pub struct ConsoleTransform; + +impl ConfigTransform for ConsoleTransform { + #[tracing::instrument(skip(config))] + fn transform(&self, config: &mut VmConfig) -> Result<()> { + trace!("Applying ConsoleTransform"); + config.console = Some(ConsoleConfig { + mode: cloud_hypervisor_client::models::console_config::Mode::Pty, + ..Default::default() + }); + Ok(()) + } +} + +#[derive(Default)] +pub struct TransformChain(Vec>); + +impl TransformChain { + pub fn new() -> Self { + Self(vec![]) + } + + pub fn add(mut self, transform: T) -> Self { + self.0.push(Box::new(transform)); + self + } + + pub fn then(self) -> Box { + Box::new(self) + } +} + +impl ConfigTransform for TransformChain { + fn transform(&self, config: &mut VmConfig) -> Result<()> { + for t in &self.0 { + t.transform(config)?; + } + Ok(()) + } +} + +pub fn apply_builtin_transforms(config: &mut VmConfig) -> Result<()> { + TransformChain::new() + .add(ConsoleTransform) + .then() + .transform(config) +} diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 8d0d60d..74e14b0 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -24,9 +24,8 @@ ExecStartPre=/usr/local/libexec/odorobo-preflight %i # the actual VM process ExecStart=/usr/bin/cloud-hypervisor \ --api-socket /run/odorobo/vms/%i/ch.sock \ - --log-file /var/log/odorobo/%i.log \ - --serial tty \ - --console off + --log-file /var/log/odorobo/%i.log + ExecStartPost=/usr/local/libexec/odorobo-postflight %i ExecStop=/usr/local/libexec/odorobo-stop %i From 89558d323183b5d9adb426e640b758b436265f36 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 14:17:23 +0700 Subject: [PATCH 007/189] refactor transformations --- odorobo-agent/src/state/transform/console.rs | 20 ++++++++ .../state/{transform.rs => transform/mod.rs} | 22 +++----- .../src/state/transform/path_verify.rs | 50 +++++++++++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 odorobo-agent/src/state/transform/console.rs rename odorobo-agent/src/state/{transform.rs => transform/mod.rs} (62%) create mode 100644 odorobo-agent/src/state/transform/path_verify.rs diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs new file mode 100644 index 0000000..5cd5ee0 --- /dev/null +++ b/odorobo-agent/src/state/transform/console.rs @@ -0,0 +1,20 @@ +use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use stable_eyre::Result; +use tracing::trace; + +use super::ConfigTransform; + +#[derive(Debug, Clone)] +pub struct ConsoleTransform; + +impl ConfigTransform for ConsoleTransform { + #[tracing::instrument(skip(config))] + fn transform(&self, config: &mut VmConfig) -> Result<()> { + trace!("Applying ConsoleTransform"); + config.console = Some(ConsoleConfig { + mode: cloud_hypervisor_client::models::console_config::Mode::Pty, + ..Default::default() + }); + Ok(()) + } +} diff --git a/odorobo-agent/src/state/transform.rs b/odorobo-agent/src/state/transform/mod.rs similarity index 62% rename from odorobo-agent/src/state/transform.rs rename to odorobo-agent/src/state/transform/mod.rs index 2d414b7..85cb4bb 100644 --- a/odorobo-agent/src/state/transform.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -1,25 +1,14 @@ -use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use cloud_hypervisor_client::models::VmConfig; use stable_eyre::Result; -use tracing::trace; pub trait ConfigTransform: Send + Sync { fn transform(&self, config: &mut VmConfig) -> Result<()>; } -#[derive(Debug, Clone)] -pub struct ConsoleTransform; - -impl ConfigTransform for ConsoleTransform { - #[tracing::instrument(skip(config))] - fn transform(&self, config: &mut VmConfig) -> Result<()> { - trace!("Applying ConsoleTransform"); - config.console = Some(ConsoleConfig { - mode: cloud_hypervisor_client::models::console_config::Mode::Pty, - ..Default::default() - }); - Ok(()) - } -} +mod console; +mod path_verify; +pub use console::ConsoleTransform; +pub use path_verify::PathVerify; #[derive(Default)] pub struct TransformChain(Vec>); @@ -51,6 +40,7 @@ impl ConfigTransform for TransformChain { pub fn apply_builtin_transforms(config: &mut VmConfig) -> Result<()> { TransformChain::new() .add(ConsoleTransform) + .add(PathVerify) .then() .transform(config) } diff --git a/odorobo-agent/src/state/transform/path_verify.rs b/odorobo-agent/src/state/transform/path_verify.rs new file mode 100644 index 0000000..7a6564e --- /dev/null +++ b/odorobo-agent/src/state/transform/path_verify.rs @@ -0,0 +1,50 @@ +//! Path verification transformer module +//! +//! Verifies paths for validity, returning errors when an invalid path is found +use std::path::Path; + +use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use stable_eyre::{Result, eyre::eyre}; +use tracing::trace; + +use super::ConfigTransform; + +#[derive(Debug, Clone)] +pub struct PathVerify; + +impl ConfigTransform for PathVerify { + #[tracing::instrument(skip(config))] + fn transform(&self, config: &mut VmConfig) -> Result<()> { + trace!("Verifying paths"); + let config = config.clone(); + // payload path verification + if let Some(kernel_path) = config.payload.kernel { + if !Path::new(&kernel_path).is_absolute() { + return Err(eyre!("Kernel must be an absolute path")); + } + } + if let Some(initramfs_path) = config.payload.initramfs { + if !Path::new(&initramfs_path).is_absolute() { + return Err(eyre!("initramfs must be an absolute path")); + } + } + if let Some(firmware_path) = config.payload.firmware { + if !Path::new(&firmware_path).is_absolute() { + return Err(eyre!("firmware must be an absolute path")); + } + } + + // storage path verification + if let Some(disk_configs) = config.disks { + for disk in disk_configs { + if let Some(path) = disk.path { + let disk_id = disk.id.unwrap_or("".into()); + if !Path::new(&path).is_absolute() { + return Err(eyre!("disk path for {disk_id} must be an absolute path")); + } + } + } + } + Ok(()) + } +} From 98e031bc5a9efbdb59caf52475353062787ce642 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 15:44:04 +0700 Subject: [PATCH 008/189] basic REST API --- Cargo.lock | 312 ++++++++++++++++++++++++++++ Cargo.toml | 6 +- odorobo-agent/Cargo.toml | 5 + odorobo-agent/src/api/error.rs | 21 ++ odorobo-agent/src/api/mod.rs | 19 ++ odorobo-agent/src/api/vm.rs | 76 +++++++ odorobo-agent/src/main.rs | 10 +- odorobo-agent/src/state/instance.rs | 33 ++- systemd/odorobo-ch@.service | 2 +- 9 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 odorobo-agent/src/api/error.rs create mode 100644 odorobo-agent/src/api/mod.rs create mode 100644 odorobo-agent/src/api/vm.rs diff --git a/Cargo.lock b/Cargo.lock index f2e9364..9f89de5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -29,6 +38,116 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "form_urlencoded", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum_responses" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232b77442c6208daba315aa9cc2f1903c8645aefe02a1e9863de7efd8dacb6a8" +dependencies = [ + "axum", + "axum_responses_macros", + "chrono", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "axum_responses_macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab84ecadf0586fb9bce16f441345a2fc392ca08f3aa725665b414ce739301417" +dependencies = [ + "axum", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backtrace" version = "0.3.71" @@ -94,6 +213,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cloud-hypervisor-client" version = "0.3.3" @@ -115,6 +247,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -446,6 +584,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -627,12 +789,24 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -662,6 +836,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.32.2" @@ -675,13 +858,18 @@ dependencies = [ name = "odorobo-agent" version = "0.1.0" dependencies = [ + "axum", + "axum-extra", + "axum_responses", "cloud-hypervisor-client", "http-body-util", "hyper", "hyper-util", "hyperlocal", + "serde", "serde_json", "stable-eyre", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -798,6 +986,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "safemem" version = "0.2.0" @@ -846,6 +1040,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -859,6 +1066,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -870,6 +1088,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -945,6 +1175,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1036,6 +1272,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1048,6 +1306,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1265,12 +1524,65 @@ dependencies = [ "semver", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index fbd771b..f42df19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,8 @@ stable-eyre = "0.2.2" tracing = "0.1" tracing-subscriber = "0.3" cloud-hypervisor-client = "0.3" -serde_json = "1.0.149" \ No newline at end of file +serde_json = "1.0.149" +serde = "1.0" +axum = "0.8.8" +axum-responses = "0.5.5" +axum-extra = "0.12" \ No newline at end of file diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 1a2bf23..97731f4 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -12,6 +12,11 @@ cloud-hypervisor-client = { workspace = true } http-body-util = "0.1.3" hyper = { version = "1.8.1", features = ["full"] } hyper-util = { version = "0.1.20", features = ["client", "client-legacy", "http1", "http2"] } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } hyperlocal = "0.9.1" tokio = { version = "1.50.0", features = ["full"] } +axum = { workspace = true } +axum-extra = { workspace = true, features = ["query"] } +axum_responses = "0.5.5" +thiserror = "2.0.18" diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs new file mode 100644 index 0000000..c0f0f26 --- /dev/null +++ b/odorobo-agent/src/api/error.rs @@ -0,0 +1,21 @@ +use axum_responses::HttpError; +use thiserror::Error; + +#[derive(Debug, Error, HttpError)] +pub enum ApiError { + #[error("VM not found: {0}")] + #[http(code = 404)] + VmNotFound(String), + + #[error("Failed to get VM info")] + #[http(code = 500)] + VmInfoFailed, + + #[error("Failed to list VMs")] + #[http(code = 500)] + ListFailed, + + #[error("Failed to create VM")] + #[http(code = 500)] + CreateFailed, +} diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs new file mode 100644 index 0000000..0824c43 --- /dev/null +++ b/odorobo-agent/src/api/mod.rs @@ -0,0 +1,19 @@ +//! REST Management API for odorobo-agent +mod error; +pub use error::ApiError; + +mod vm; +pub fn router() -> axum::Router<()> { + axum::Router::new() + .route("/", axum::routing::get(root)) + .route("/health", axum::routing::get(health)) + .nest("/vms", vm::router()) +} + +async fn root() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +async fn health() -> &'static str { + "" +} diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs new file mode 100644 index 0000000..9a624d8 --- /dev/null +++ b/odorobo-agent/src/api/vm.rs @@ -0,0 +1,76 @@ +use axum::{ + Json, + extract::{Path, Query}, +}; +use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; +use serde::Deserialize; + +use super::error::ApiError; +use crate::state::VMInstance; + +pub fn router() -> axum::Router<()> { + axum::Router::new() + .route("/", axum::routing::get(list_vms)) + .route("/", axum::routing::put(create_vm)) + .route("/{vmid}", axum::routing::get(vm_info)) + .route("/{vmid}/ping", axum::routing::get(ping_vm)) +} + +async fn list_vms() -> Result>, ApiError> { + let vms = VMInstance::list().map_err(|_| ApiError::ListFailed)?; + Ok(Json(vms.into_iter().map(|i| i.id).collect())) +} + +fn get_vm(vmid: &str) -> Result { + VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound(vmid.to_string())) +} + +async fn vm_info( + vmid: Path, +) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + + let info = vm.info().await.map_err(|_| ApiError::VmInfoFailed)?; + Ok(Json(info)) +} +/// Pings the VMM to check if it's running +async fn ping_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + let res = vm.ping().await.map_err(|_| ApiError::VmInfoFailed)?; + Ok(Json(res)) +} + +#[derive(Debug, Deserialize)] +pub struct CreateVmQuery { + #[serde(default)] + /// Whether to boot the VM immediately + /// after creation + /// + /// Defaults to `false`. + pub boot: bool, +} + +/// Create a new VM +async fn create_vm( + vmid: Path, + Query(query): Query, + Json(vm_config): Json, +) -> Result, ApiError> { + let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); + std::fs::create_dir_all(&runtime_dir).map_err(|_| ApiError::CreateFailed)?; + + let socket_path = runtime_dir.join("ch.sock"); + let vm = VMInstance::new(&vmid.0, socket_path); + vm.create(vm_config, query.boot) + .await + .map_err(|_| ApiError::CreateFailed)?; + + let info = vm.info().await.map_err(|_| ApiError::VmInfoFailed)?; + Ok(Json(info)) +} + +async fn destroy_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.destroy().await.map_err(|_| ApiError::VmInfoFailed)?; + Ok(Json(())) +} diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 7e9442d..8acd740 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,7 +1,6 @@ +mod api; mod state; - use stable_eyre::Result; -use state::VMInstance; #[tokio::main] async fn main() -> Result<()> { stable_eyre::install()?; @@ -11,8 +10,11 @@ async fn main() -> Result<()> { tracing::info!("Starting odorobo-agent..."); - let list = VMInstance::list()?; - tracing::info!("VM Instances: {list:#?}"); + // minimal axum server + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; + tracing::info!("Listening on http://{}", listener.local_addr()?); + axum::serve(listener, api::router()).await?; Ok(()) } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 3f7a958..74293de 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -1,7 +1,7 @@ use cloud_hypervisor_client::{ SocketBasedApiClient, apis::DefaultApi, - models::{self, VmInfo}, + models::{self, VmInfo, VmmPingResponse}, }; use hyper::Method; use serde_json::Value; @@ -39,6 +39,10 @@ impl VMInstance { } } + pub fn get(vmid: &str) -> Option { + Self::list().ok()?.into_iter().find(|i| i.id == vmid) + } + pub fn runtime_root() -> PathBuf { Self::configured_runtime_root().join(VMS_DIR_NAME) } @@ -73,20 +77,27 @@ impl VMInstance { &self.ch_socket_path } - async fn info(&self) -> Result { + pub async fn info(&self) -> Result { self.conn() .vm_info_get() .await .wrap_err(eyre!("Failed to get VM info for {}", self.vm_id())) } - async fn shutdown(&self) -> Result<()> { + pub async fn shutdown(&self) -> Result<()> { self.conn() .shutdown_vm() .await .wrap_err(eyre!("Failed to shutdown VM {}", self.vm_id())) } + pub async fn ping(&self) -> Result { + self.conn() + .vmm_ping_get() + .await + .wrap_err(eyre!("Failed to ping VM {}", self.vm_id())) + } + pub async fn console_socket(&self) -> Result { let vminfo = self.info().await?; if let Some(console) = vminfo.config.console { @@ -208,8 +219,8 @@ impl VMInstance { /// /// Applies node-specific transforms, saves config to disk, then: /// 1. Creates the VM via CH API - /// 2. Boots the VM - pub async fn create_and_boot(&self, config: models::VmConfig) -> Result<()> { + /// 2. Boots the VM (if boot is true) + pub async fn create(&self, config: models::VmConfig, boot: bool) -> Result<()> { let mut config = config; apply_builtin_transforms(&mut config).wrap_err("Failed to apply config transforms")?; @@ -219,11 +230,13 @@ impl VMInstance { .await .wrap_err(eyre!("Failed to create VM {}", self.vm_id()))?; - self.conn() - .boot_vm() - .await - .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; - + if boot { + debug!(vm_id = self.vm_id(), "Booting VM"); + self.conn() + .boot_vm() + .await + .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; + } info!(vm_id = self.vm_id(), "VM created and booted"); Ok(()) } diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 74e14b0..00f1976 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -22,7 +22,7 @@ ConfigurationDirectoryMode=0750 ExecStartPre=/usr/local/libexec/odorobo-preflight %i # the actual VM process -ExecStart=/usr/bin/cloud-hypervisor \ +ExecStart=/usr/bin/cloud-hypervisor -vv \ --api-socket /run/odorobo/vms/%i/ch.sock \ --log-file /var/log/odorobo/%i.log From d7965b94a9d2e18f26261cfa50baf25dbd6fe68d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 16:18:07 +0700 Subject: [PATCH 009/189] log to stderr instead, at INFO level --- systemd/odorobo-ch@.service | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 00f1976..bc5bb6a 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -22,10 +22,8 @@ ConfigurationDirectoryMode=0750 ExecStartPre=/usr/local/libexec/odorobo-preflight %i # the actual VM process -ExecStart=/usr/bin/cloud-hypervisor -vv \ - --api-socket /run/odorobo/vms/%i/ch.sock \ - --log-file /var/log/odorobo/%i.log - +ExecStart=/usr/bin/cloud-hypervisor \ + --api-socket /run/odorobo/vms/%i/ch.sock ExecStartPost=/usr/local/libexec/odorobo-postflight %i ExecStop=/usr/local/libexec/odorobo-stop %i From 30abc964473262f7890c92b1bc654daa3227c8ce Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 26 Mar 2026 16:18:49 +0700 Subject: [PATCH 010/189] remove emojis in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b799339..c405ee6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Odorobo 🤖💃 +# Odorobo > [Umicha - Odorobo](https://youtu.be/D_UC0WJmLnc) @@ -23,4 +23,4 @@ Agents run on each node and manage Cloud Hypervisor instances directly via syste ## Usage -TODO \ No newline at end of file +TODO From 43ed00522142fb7f39ed2c070c5f28d28d63a273 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 27 Mar 2026 18:32:38 +0700 Subject: [PATCH 011/189] implement websocket console --- Cargo.lock | 205 +++++++++++++++++++++++++++- Cargo.toml | 2 +- README.md | 2 +- docs/console.md | 134 ++++++++++++++++++ odorobo-agent/Cargo.toml | 4 +- odorobo-agent/src/api/console.rs | 177 ++++++++++++++++++++++++ odorobo-agent/src/api/error.rs | 4 + odorobo-agent/src/api/mod.rs | 1 + odorobo-agent/src/api/vm.rs | 8 +- odorobo-agent/src/state/instance.rs | 27 +++- odorobo-agent/src/state/mod.rs | 2 +- 11 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 docs/console.md create mode 100644 odorobo-agent/src/api/console.rs diff --git a/Cargo.lock b/Cargo.lock index 9f89de5..a6bb6cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -69,8 +70,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -173,12 +176,27 @@ dependencies = [ "safemem", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -232,7 +250,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a135a9339a5ad2775b8843dfad19cd5057e9d8092a3c09b523c5030fcc1348ee" dependencies = [ - "base64", + "base64 0.7.0", "futures", "http 0.2.12", "http-body-util", @@ -253,6 +271,41 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -405,6 +458,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -413,7 +488,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -862,10 +937,12 @@ dependencies = [ "axum-extra", "axum_responses", "cloud-hypervisor-client", + "futures-util", "http-body-util", "hyper", "hyper-util", "hyperlocal", + "libc", "serde", "serde_json", "stable-eyre", @@ -931,6 +1008,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -959,12 +1045,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1100,6 +1221,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1259,6 +1391,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1364,6 +1508,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1388,6 +1555,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1400,7 +1573,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -1412,6 +1585,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1709,6 +1888,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index f42df19..f1a37cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,6 @@ tracing-subscriber = "0.3" cloud-hypervisor-client = "0.3" serde_json = "1.0.149" serde = "1.0" -axum = "0.8.8" +axum = { version = "0.8.8", features = ["ws"] } axum-responses = "0.5.5" axum-extra = "0.12" \ No newline at end of file diff --git a/README.md b/README.md index c405ee6..d7b1273 100644 --- a/README.md +++ b/README.md @@ -23,4 +23,4 @@ Agents run on each node and manage Cloud Hypervisor instances directly via syste ## Usage -TODO +See `docs/console.md` for PTY-over-WebSocket usage and integration details. diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 0000000..ef22fc3 --- /dev/null +++ b/docs/console.md @@ -0,0 +1,134 @@ +# Console API + +## PTY over WebSocket + +The agent exposes a VM console bridge at: + +`GET /vms/{vmid}/console` + +This endpoint upgrades to a WebSocket and proxies the VM's PTY console in both directions. + +## Connection behavior + +- Connect with a standard WebSocket client to `ws://:8890/vms//console` +- The upgrade succeeds only if the VM exists and the agent can open its PTY console +- If the VM does not exist, the HTTP request returns `404` +- If the PTY cannot be opened, the HTTP request returns `500` +- After the upgrade completes, terminal bytes flow over the socket until either side disconnects + +## Frame semantics + +- Client -> VM: + - WebSocket `Binary` frames are written to the PTY as-is + - WebSocket `Text` frames are reserved for JSON control messages +- VM -> Client: + - PTY output is sent back as WebSocket `Binary` frames + - Server control or protocol errors are sent as WebSocket `Text` frames containing JSON +- Control frames: + - WebSocket `Ping` receives a `Pong` + - WebSocket `Close` closes the session + +In practice, treat this as a raw byte stream carried over WebSocket frames. + +## Resize message + +Clients resize the PTY by sending a JSON text frame like: + +```json +{"type":"resize","cols":120,"rows":40} +``` + +Optional pixel dimensions are also supported: + +```json +{"type":"resize","cols":120,"rows":40,"x_pixels":960,"y_pixels":720} +``` + +The agent applies the new TTY window size on the host PTY with `TIOCSWINSZ`, so the guest can observe the updated size through normal terminal mechanisms. + +## Reset-session message + +Clients can ask the agent to try to return the console to a fresh login-like state by sending: + +```json +{"type":"reset_session"} +``` + +This sends a conservative control sequence to the PTY: + +- `Ctrl-C` to interrupt a foreground shell command if possible +- `Enter` to try to land on a clean prompt +- `Ctrl-D` to request EOF/logout from the current shell or login program + +This is best-effort only. The guest decides what those bytes mean. + +## Important implementation notes + +- Do not assume one terminal message maps to one WebSocket frame; PTY output is chunked arbitrarily +- Send terminal input as binary bytes, not text frames +- Text frames should be treated as a small control channel for messages like resize requests, reset requests, and error events +- If the server receives an invalid control message, it responds with a JSON text frame like `{"type":"error","message":"..."}` and keeps the session open +- This API is transport-only; terminal emulation, ANSI parsing, scrollback, and rendering are client responsibilities +- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on `hvc0` +- On virtio-console or serial-backed Linux sessions, `Ctrl-D` only causes a logout when the foreground process interprets it as EOF +- If the guest is running `vim`, `less`, a full-screen app, or a raw-mode program, `reset_session` may not produce a fresh login prompt +- For deterministic fresh sessions, configure the guest to respawn `getty` on the console device after shell exit + +## Browser example + +```js +const ws = new WebSocket("ws://127.0.0.1:8890/vms/my-vm/console"); +ws.binaryType = "arraybuffer"; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +ws.addEventListener("open", () => { + // Send a command to the guest console. + ws.send(encoder.encode("uname -a\n")); + ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 })); +}); + +ws.addEventListener("message", async (event) => { + if (typeof event.data === "string") { + const control = JSON.parse(event.data); + if (control.type === "error") { + console.error(control.message); + } + return; + } + + const data = event.data instanceof ArrayBuffer + ? new Uint8Array(event.data) + : new Uint8Array(await event.data.arrayBuffer()); + + const text = decoder.decode(data); + console.log(text); +}); + +window.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + ws.send(new Uint8Array([13])); + return; + } + + if (event.key.length === 1) { + ws.send(encoder.encode(event.key)); + } +}); + +function resizeTerminal(cols, rows) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); +} + +function resetSession() { + ws.send(JSON.stringify({ type: "reset_session" })); +} +``` + +## CLI example with websocat + +```bash +websocat --binary ws://127.0.0.1:8890/vms/my-vm/console +``` + +This is the simplest way to verify the bridge works before integrating it into a browser terminal such as xterm.js. diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 97731f4..88fc65c 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -16,7 +16,9 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } hyperlocal = "0.9.1" tokio = { version = "1.50.0", features = ["full"] } -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } axum-extra = { workspace = true, features = ["query"] } axum_responses = "0.5.5" thiserror = "2.0.18" +futures-util = "0.3.31" +libc = "0.2" diff --git a/odorobo-agent/src/api/console.rs b/odorobo-agent/src/api/console.rs new file mode 100644 index 0000000..9bd7ca8 --- /dev/null +++ b/odorobo-agent/src/api/console.rs @@ -0,0 +1,177 @@ +use axum::{ + extract::{ + Path, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::Response, +}; +use futures_util::SinkExt; +use serde::{Deserialize, Serialize}; +use stable_eyre::{Result, eyre::eyre}; +use std::os::fd::AsRawFd; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{Duration, sleep}; +use tracing::{debug, warn}; + +use super::error::ApiError; +use crate::state::{ConsoleStream, VMInstance}; + +pub async fn console_stream( + vmid: Path, + ws: WebSocketUpgrade, +) -> Result { + let vmid = vmid.0; + let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound(vmid.clone()))?; + let console = vm.open_console().await.map_err(|_| ApiError::ConsoleFailed)?; + + Ok(ws.on_upgrade(move |socket| proxy_console_socket(vmid, socket, console))) +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ConsoleControlMessage { + Resize { + cols: u16, + rows: u16, + #[serde(default)] + x_pixels: u16, + #[serde(default)] + y_pixels: u16, + }, + ResetSession, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ConsoleServerMessage<'a> { + Error { message: &'a str }, +} + +async fn proxy_console_socket(vm_id: String, mut socket: WebSocket, mut console: ConsoleStream) { + match proxy_console(&mut socket, &mut console).await { + Ok(()) => debug!(vm_id, "Console websocket disconnected"), + Err(err) => warn!(vm_id, ?err, "Console websocket proxy failed"), + } + + let _ = socket.close().await; +} + +async fn proxy_console(socket: &mut WebSocket, console: &mut ConsoleStream) -> Result<()> { + let mut buf = [0_u8; 8192]; + + loop { + tokio::select! { + message = socket.recv() => { + match message { + Some(Ok(Message::Binary(data))) => console.write_all(&data).await?, + Some(Ok(Message::Text(text))) => handle_console_control(socket, console, text.to_string()).await?, + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(payload))) => socket.send(Message::Pong(payload)).await?, + Some(Ok(Message::Pong(_))) => {} + Some(Err(err)) => return Err(err.into()), + } + } + read = console.read(&mut buf) => { + let read = read?; + if read == 0 { + break; + } + + socket.send(Message::Binary(buf[..read].to_vec().into())).await?; + } + } + } + + Ok(()) +} + +async fn handle_console_control( + socket: &mut WebSocket, + console: &mut ConsoleStream, + raw_message: String, +) -> Result<()> { + let message: ConsoleControlMessage = match serde_json::from_str(&raw_message) { + Ok(message) => message, + Err(_) => { + send_console_event( + socket, + ConsoleServerMessage::Error { + message: "Text frames are reserved for JSON control messages such as {\"type\":\"resize\",\"cols\":120,\"rows\":40} or {\"type\":\"reset_session\"}", + }, + ) + .await?; + return Ok(()); + } + }; + + match message { + ConsoleControlMessage::Resize { + cols, + rows, + x_pixels, + y_pixels, + } => { + if let Err(err) = resize_console(console, cols, rows, x_pixels, y_pixels) { + let message = format!("Failed to resize console: {err}"); + send_console_event(socket, ConsoleServerMessage::Error { message: &message }).await?; + } + } + ConsoleControlMessage::ResetSession => { + if let Err(err) = reset_console_session(console).await { + let message = format!("Failed to reset console session: {err}"); + send_console_event(socket, ConsoleServerMessage::Error { message: &message }).await?; + } + } + } + + Ok(()) +} + +async fn send_console_event(socket: &mut WebSocket, event: ConsoleServerMessage<'_>) -> Result<()> { + let payload = serde_json::to_string(&event)?; + socket.send(Message::Text(payload.into())).await?; + Ok(()) +} + +fn resize_console( + console: &ConsoleStream, + cols: u16, + rows: u16, + x_pixels: u16, + y_pixels: u16, +) -> Result<()> { + if cols == 0 || rows == 0 { + return Err(eyre!("Console dimensions must be greater than zero")); + } + + let size = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: x_pixels, + ws_ypixel: y_pixels, + }; + + let result = unsafe { libc::ioctl(console.as_raw_fd(), libc::TIOCSWINSZ, &size) }; + if result == -1 { + return Err(std::io::Error::last_os_error().into()); + } + + Ok(()) +} + +async fn reset_console_session(console: &mut ConsoleStream) -> Result<()> { + const STEP_DELAY: Duration = Duration::from_millis(75); + + console.write_all(&[0x03]).await?; + console.flush().await?; + sleep(STEP_DELAY).await; + + console.write_all(b"\r").await?; + console.flush().await?; + sleep(STEP_DELAY).await; + + console.write_all(&[0x04]).await?; + console.flush().await?; + + Ok(()) +} diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index c0f0f26..a551a4d 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -18,4 +18,8 @@ pub enum ApiError { #[error("Failed to create VM")] #[http(code = 500)] CreateFailed, + + #[error("Failed to open VM console")] + #[http(code = 500)] + ConsoleFailed, } diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index 0824c43..52986e9 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -1,4 +1,5 @@ //! REST Management API for odorobo-agent +mod console; mod error; pub use error::ApiError; diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 9a624d8..3bc350e 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -1,9 +1,7 @@ -use axum::{ - Json, - extract::{Path, Query}, -}; +use axum::{Json, extract::{Path, Query}}; use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; use serde::Deserialize; +use stable_eyre::Result; use super::error::ApiError; use crate::state::VMInstance; @@ -14,6 +12,8 @@ pub fn router() -> axum::Router<()> { .route("/", axum::routing::put(create_vm)) .route("/{vmid}", axum::routing::get(vm_info)) .route("/{vmid}/ping", axum::routing::get(ping_vm)) + .route("/{vmid}", axum::routing::delete(destroy_vm)) + .route("/{vmid}/console", axum::routing::get(super::console::console_stream)) } async fn list_vms() -> Result>, ApiError> { diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 74293de..4550318 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -11,6 +11,7 @@ use stable_eyre::{ }; use std::{ env, fs, + fs::OpenOptions, path::{Path, PathBuf}, }; use tracing::{debug, info, warn}; @@ -21,6 +22,7 @@ use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; pub const VMS_DIR_NAME: &str = "vms"; +pub type ConsoleStream = tokio::fs::File; const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; @@ -73,6 +75,23 @@ impl VMInstance { &self.id } + /// Opens the PTY console device for this VM and returns a connected stream. + pub async fn open_console(&self) -> Result { + let console_path = self.console_path().await?; + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&console_path) + .wrap_err_with(|| { + eyre!( + "Failed to open PTY console device for {} at {}", + self.vm_id(), + console_path.display() + ) + })?; + Ok(tokio::fs::File::from_std(file)) + } + pub fn ch_socket_path(&self) -> &Path { &self.ch_socket_path } @@ -98,19 +117,19 @@ impl VMInstance { .wrap_err(eyre!("Failed to ping VM {}", self.vm_id())) } - pub async fn console_socket(&self) -> Result { + pub async fn console_path(&self) -> Result { let vminfo = self.info().await?; if let Some(console) = vminfo.config.console { match console.mode { models::console_config::Mode::Pty => { debug!( vm_id = self.vm_id(), - "VM has PTY console configured, returning socket path" + "VM has PTY console configured, returning PTY path" ); - let socket_path = console.file.ok_or_else(|| { + let console_path = console.file.ok_or_else(|| { eyre!("Console config is missing file path for PTY console") })?; - Ok(socket_path.into()) + Ok(console_path.into()) } _ => Err(eyre!( "Console is configured but is not a PTY console, unsupported console type" diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index a83124e..15f0827 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -8,5 +8,5 @@ mod instance; mod transform; pub use api::call; -pub use instance::{CONFIG_FILE_NAME, VMInstance, VMS_DIR_NAME}; +pub use instance::{CONFIG_FILE_NAME, ConsoleStream, VMInstance, VMS_DIR_NAME}; pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; From 194766ca3f917eeaf35ad89e2edda342e4d9691c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 27 Mar 2026 19:06:23 +0700 Subject: [PATCH 012/189] add passthrough api --- Cargo.lock | 17 ++++++ Cargo.toml | 3 +- README.md | 1 + docs/ch-passthrough.md | 78 ++++++++++++++++++++++++ justfile | 22 +++++++ odorobo-agent/Cargo.toml | 1 + odorobo-agent/src/api/ch.rs | 39 ++++++++++++ odorobo-agent/src/api/error.rs | 4 ++ odorobo-agent/src/api/mod.rs | 5 +- odorobo-agent/src/api/vm.rs | 19 +++++- odorobo-agent/src/state/api.rs | 92 +++++++++++++++++++++-------- odorobo-agent/src/state/instance.rs | 10 +++- odorobo-agent/src/state/mod.rs | 2 +- 13 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 docs/ch-passthrough.md create mode 100644 justfile create mode 100644 odorobo-agent/src/api/ch.rs diff --git a/Cargo.lock b/Cargo.lock index a6bb6cb..092dc88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,6 +948,7 @@ dependencies = [ "stable-eyre", "thiserror", "tokio", + "tower-http", "tracing", "tracing-subscriber", ] @@ -1432,6 +1433,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http 1.4.0", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index f1a37cf..fa2fef9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,5 @@ serde_json = "1.0.149" serde = "1.0" axum = { version = "0.8.8", features = ["ws"] } axum-responses = "0.5.5" -axum-extra = "0.12" \ No newline at end of file +axum-extra = "0.12" +tower-http = { version = "0.6", features = ["trace"] } \ No newline at end of file diff --git a/README.md b/README.md index d7b1273..51fc6c2 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,4 @@ Agents run on each node and manage Cloud Hypervisor instances directly via syste ## Usage See `docs/console.md` for PTY-over-WebSocket usage and integration details. +See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. diff --git a/docs/ch-passthrough.md b/docs/ch-passthrough.md new file mode 100644 index 0000000..b976ded --- /dev/null +++ b/docs/ch-passthrough.md @@ -0,0 +1,78 @@ +# Cloud Hypervisor Passthrough API + +The agent exposes a passthrough route for the local Cloud Hypervisor API at: + +`/{vmid}/ch/{*path}` + +This lets clients call Cloud Hypervisor's Unix-socket HTTP API through the agent's normal HTTP listener. + +## Path mapping + +Requests are rewritten like this: + +- Agent route: `/{vmid}/ch/vm.info` +- Cloud Hypervisor route: `/api/v1/vm.info` + +The passthrough automatically prefixes the requested path with `/api/v1/` before forwarding it to the VM's `ch.sock` Unix socket. + +## Behavior + +- Any HTTP method is accepted: `GET`, `PUT`, `POST`, `PATCH`, `DELETE`, and so on +- Request bodies are forwarded as-is +- Query strings are preserved +- Most request headers are forwarded through unchanged +- `Host` and `Content-Length` are not forwarded directly; the HTTP client rebuilds them for the Unix-socket request +- The response status, headers, and body from Cloud Hypervisor are returned directly to the caller + +## Error handling + +- If the VM does not exist, the agent returns `404` +- If the agent cannot reach the VM's Cloud Hypervisor socket, the agent returns `500` +- If Cloud Hypervisor itself returns an error like `400`, `404`, or `500`, that exact response is passed back to the caller + +## Examples + +Fetch VM information: + +```bash +curl http://127.0.0.1:8890/vms/my-vm/ch/vm.info +``` + +Ping the VMM: + +```bash +curl http://127.0.0.1:8890/vms/my-vm/ch/vmm.ping +``` + +Pause a VM: + +```bash +curl -X PUT http://127.0.0.1:8890/vms/my-vm/ch/vm.pause +``` + +Resume a VM: + +```bash +curl -X PUT http://127.0.0.1:8890/vms/my-vm/ch/vm.resume +``` + +Resize a VM with a JSON body: + +```bash +curl -X PUT \ + -H 'content-type: application/json' \ + -d '{"desired_vcpus":2,"desired_ram":1073741824}' \ + http://127.0.0.1:8890/vms/my-vm/ch/vm.resize +``` + +Pass through a query string: + +```bash +curl "http://127.0.0.1:8890/vms/my-vm/ch/some.endpoint?foo=bar&baz=qux" +``` + +## Notes + +- This route is intentionally low-level and mirrors Cloud Hypervisor's API fairly directly +- It is useful for debugging, advanced control flows, and reaching endpoints that do not yet have first-class agent routes +- Clients should still prefer dedicated agent routes when available, because those routes can provide better validation and more stable semantics diff --git a/justfile b/justfile new file mode 100644 index 0000000..4662d4e --- /dev/null +++ b/justfile @@ -0,0 +1,22 @@ +PREFIX := "/usr/local" +BINDIR := PREFIX / "bin" +LIBEXECDIR := PREFIX / "libexec" +DATADIR := PREFIX / "share" + +build: build_agent + +build_agent: + cargo build --release -p odorobo-agent + +install: install_script install_unit install_agent + +install_script: + install -Dm755 systemd/scripts/odorobo-preflight {{ LIBEXECDIR }}/odorobo-preflight + install -Dm755 systemd/scripts/odorobo-postflight {{ LIBEXECDIR }}/odorobo-postflight + install -Dm755 systemd/scripts/odorobo-cleanup {{ LIBEXECDIR }}/odorobo-cleanup + +install_unit: + install -Dm644 systemd/odorobo-ch@.service {{ DATADIR }}/systemd/user/odorobo-ch@.service + +install_agent: + install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 88fc65c..d09db0d 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -22,3 +22,4 @@ axum_responses = "0.5.5" thiserror = "2.0.18" futures-util = "0.3.31" libc = "0.2" +tower-http = { workspace = true, features = ["trace"] } diff --git a/odorobo-agent/src/api/ch.rs b/odorobo-agent/src/api/ch.rs new file mode 100644 index 0000000..0ca8c23 --- /dev/null +++ b/odorobo-agent/src/api/ch.rs @@ -0,0 +1,39 @@ +use axum::{ + body::Body, + extract::{Path, Request}, + response::Response, +}; +use http_body_util::BodyExt; + +use super::error::ApiError; +use crate::state::VMInstance; + +pub async fn passthrough( + Path((vmid, path)): Path<(String, String)>, + request: Request, +) -> Result { + let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound(vmid.clone()))?; + + let (mut parts, body) = request.into_parts(); + let body = body + .collect() + .await + .map_err(|_| ApiError::PassthroughFailed)? + .to_bytes(); + + let path_and_query = match parts.uri.query() { + Some(query) => format!("/{path}?{query}"), + None => format!("/{path}"), + }; + parts.uri = path_and_query + .parse() + .map_err(|_| ApiError::PassthroughFailed)?; + + let response = vm + .call_request(hyper::Request::from_parts(parts, body)) + .await + .map_err(|_| ApiError::PassthroughFailed)?; + + let (parts, body) = response.into_parts(); + Ok(Response::from_parts(parts, Body::from(body))) +} diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index a551a4d..01ed980 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -22,4 +22,8 @@ pub enum ApiError { #[error("Failed to open VM console")] #[http(code = 500)] ConsoleFailed, + + #[error("Failed to proxy Cloud Hypervisor API request")] + #[http(code = 500)] + PassthroughFailed, } diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index 52986e9..f58715a 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -1,11 +1,12 @@ //! REST Management API for odorobo-agent +mod ch; mod console; mod error; -pub use error::ApiError; - +use tower_http::trace::TraceLayer; mod vm; pub fn router() -> axum::Router<()> { axum::Router::new() + .layer(TraceLayer::new_for_http()) .route("/", axum::routing::get(root)) .route("/health", axum::routing::get(health)) .nest("/vms", vm::router()) diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 3bc350e..00b40a6 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -1,4 +1,7 @@ -use axum::{Json, extract::{Path, Query}}; +use axum::{ + Json, + extract::{Path, Query}, +}; use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; use serde::Deserialize; use stable_eyre::Result; @@ -13,18 +16,28 @@ pub fn router() -> axum::Router<()> { .route("/{vmid}", axum::routing::get(vm_info)) .route("/{vmid}/ping", axum::routing::get(ping_vm)) .route("/{vmid}", axum::routing::delete(destroy_vm)) - .route("/{vmid}/console", axum::routing::get(super::console::console_stream)) + .route( + "/{vmid}/console", + axum::routing::get(super::console::console_stream), + ) + .route( + "/{vmid}/ch/{*path}", + axum::routing::any(super::ch::passthrough), + ) } +/// Lists all VMs by their IDs async fn list_vms() -> Result>, ApiError> { let vms = VMInstance::list().map_err(|_| ApiError::ListFailed)?; Ok(Json(vms.into_iter().map(|i| i.id).collect())) } +/// Helper function to get a VM instance by ID, returning an error if not found fn get_vm(vmid: &str) -> Result { VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound(vmid.to_string())) } +/// Gets detailed information about a specific VM async fn vm_info( vmid: Path, ) -> Result, ApiError> { @@ -33,6 +46,7 @@ async fn vm_info( let info = vm.info().await.map_err(|_| ApiError::VmInfoFailed)?; Ok(Json(info)) } + /// Pings the VMM to check if it's running async fn ping_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; @@ -69,6 +83,7 @@ async fn create_vm( Ok(Json(info)) } +/// Destroys a VM, stopping it if it's running and cleaning up resources async fn destroy_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; vm.destroy().await.map_err(|_| ApiError::VmInfoFailed)?; diff --git a/odorobo-agent/src/state/api.rs b/odorobo-agent/src/state/api.rs index a4daa24..d55b059 100644 --- a/odorobo-agent/src/state/api.rs +++ b/odorobo-agent/src/state/api.rs @@ -1,5 +1,9 @@ use http_body_util::BodyExt; -use hyper::{Method, Request, header::CONTENT_TYPE}; +use hyper::{ + Method, Request, Response, + body::Bytes, + header::{CONTENT_LENGTH, CONTENT_TYPE, HOST}, +}; use hyperlocal::UnixClientExt; use serde_json::Value; use stable_eyre::{ @@ -14,34 +18,24 @@ pub async fn call( path: &str, body: Option<&Value>, ) -> Result<()> { - let normalized_path = normalize_api_path(path)?; - let api_path = format!("/api/v1{normalized_path}"); - let uri: hyper::Uri = hyperlocal::Uri::new(socket_path, &api_path).into(); - let client = hyper_util::client::legacy::Client::unix(); - let request_body = body.map(Value::to_string).unwrap_or_default(); - - let mut request = Request::builder().method(method).uri(uri); - - if body.is_some() { - request = request.header(CONTENT_TYPE, "application/json"); - } - - let request = request + let request_body = body + .map(|value| Bytes::from(value.to_string())) + .unwrap_or_default(); + let mut request = Request::builder() + .method(method) + .uri(path) .body(request_body) .wrap_err("Failed to build CH API request")?; - let response = client - .request(request) - .await - .wrap_err("Failed to send CH API request over unix socket")?; + if body.is_some() { + request + .headers_mut() + .insert(CONTENT_TYPE, "application/json".parse().unwrap()); + } + let response = call_request(socket_path, request).await?; let status = response.status(); - let response_body = response - .into_body() - .collect() - .await - .wrap_err("Failed to read CH API response body")? - .to_bytes(); + let response_body = response.into_body(); if !status.is_success() { let response_body = String::from_utf8_lossy(&response_body); @@ -56,6 +50,56 @@ pub async fn call( Ok(()) } +pub async fn call_request(socket_path: &Path, request: Request) -> Result> { + let (parts, body) = request.into_parts(); + let api_path = build_api_path(parts.uri.path(), parts.uri.query())?; + let uri: hyper::Uri = hyperlocal::Uri::new(socket_path, &api_path).into(); + let client = hyper_util::client::legacy::Client::unix(); + + let mut request = Request::builder() + .method(parts.method) + .uri(uri) + .body(http_body_util::Full::new(body)) + .wrap_err("Failed to build CH API request")?; + + for (name, value) in &parts.headers { + if name == HOST || name == CONTENT_LENGTH { + continue; + } + + request.headers_mut().append(name, value.clone()); + } + + let response = client + .request(request) + .await + .wrap_err("Failed to send CH API request over unix socket")?; + + let (parts, body) = response.into_parts(); + let response_body = body + .collect() + .await + .wrap_err("Failed to read CH API response body")? + .to_bytes(); + + let mut response = Response::builder() + .status(parts.status) + .body(response_body) + .wrap_err("Failed to build CH API response")?; + *response.headers_mut() = parts.headers; + + Ok(response) +} + +fn build_api_path(path: &str, query: Option<&str>) -> Result { + let normalized_path = normalize_api_path(path)?; + + match query.filter(|query| !query.is_empty()) { + Some(query) => Ok(format!("/api/v1{normalized_path}?{query}")), + None => Ok(format!("/api/v1{normalized_path}")), + } +} + fn normalize_api_path(path: &str) -> Result { let path = path.trim(); diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 4550318..f51ccd7 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -3,6 +3,7 @@ use cloud_hypervisor_client::{ apis::DefaultApi, models::{self, VmInfo, VmmPingResponse}, }; +use hyper::{Request, Response, body::Bytes}; use hyper::Method; use serde_json::Value; use stable_eyre::{ @@ -16,7 +17,7 @@ use std::{ }; use tracing::{debug, info, warn}; -use super::api::call; +use super::api::{call, call_request}; use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; @@ -269,6 +270,13 @@ impl VMInstance { .wrap_err(eyre!("Failed to call {} for {}", path, self.vm_id())) } + /// Proxy a raw HTTP request to the CH API socket. + pub async fn call_request(&self, request: Request) -> Result> { + call_request(self.ch_socket_path(), request) + .await + .wrap_err(eyre!("Failed to proxy CH API request for {}", self.vm_id())) + } + /// List running VM instances. /// /// Scans runtime root for directories with valid sockets. diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 15f0827..be9755b 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -7,6 +7,6 @@ mod api; mod instance; mod transform; -pub use api::call; +pub use api::{call, call_request}; pub use instance::{CONFIG_FILE_NAME, ConsoleStream, VMInstance, VMS_DIR_NAME}; pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; From fc55f911a34c765ed102a951f9442d2f718e0d03 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 14:52:26 +0700 Subject: [PATCH 013/189] proper VM management API --- Cargo.lock | 622 +++++++++++++++++- justfile | 11 +- odorobo-agent/Cargo.toml | 4 + odorobo-agent/src/api/ch.rs | 8 +- odorobo-agent/src/api/console.rs | 4 +- odorobo-agent/src/api/error.rs | 47 +- odorobo-agent/src/api/mod.rs | 8 +- odorobo-agent/src/api/vm.rs | 165 ++++- odorobo-agent/src/main.rs | 2 + odorobo-agent/src/state/instance.rs | 113 +++- odorobo-agent/src/state/mod.rs | 3 +- odorobo-agent/src/state/provisioning/hooks.rs | 1 + odorobo-agent/src/state/provisioning/mod.rs | 40 ++ .../src/state/provisioning/systemd/mod.rs | 20 + .../src/state/provisioning/systemd/proxy.rs | 96 +++ 15 files changed, 1082 insertions(+), 62 deletions(-) create mode 100644 odorobo-agent/src/state/provisioning/hooks.rs create mode 100644 odorobo-agent/src/state/provisioning/mod.rs create mode 100644 odorobo-agent/src/state/provisioning/systemd/mod.rs create mode 100644 odorobo-agent/src/state/provisioning/systemd/proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 092dc88..1e08f7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -135,7 +266,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -197,6 +328,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -260,11 +404,20 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "thiserror", + "thiserror 2.0.18", "url", "uuid", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -280,6 +433,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -317,6 +476,33 @@ dependencies = [ "syn", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -333,6 +519,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -343,6 +550,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -418,6 +631,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -468,6 +694,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -539,6 +776,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -843,6 +1086,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -876,6 +1125,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -902,6 +1160,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "network-interface" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a43439bf756eed340bdf8feba761e2d50c7d47175d87545cd5cbe4a137c4d1" +dependencies = [ + "cc", + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -943,14 +1213,17 @@ dependencies = [ "hyper-util", "hyperlocal", "libc", + "random-port", "serde", "serde_json", "stable-eyre", - "thiserror", + "thiserror 2.0.18", "tokio", "tower-http", "tracing", "tracing-subscriber", + "zbus", + "zbus_systemd", ] [[package]] @@ -959,6 +1232,22 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1000,6 +1289,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1028,6 +1342,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1058,14 +1381,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1075,7 +1419,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1087,6 +1440,17 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "random-port" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52b7d0e298a1b2f2f46c8d5da944c80ed1e5e6b032521cc44ee2b1dcbe2b94a" +dependencies = [ + "network-interface", + "rand 0.8.5", + "thiserror 1.0.69", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1102,6 +1466,19 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1325,13 +1702,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1378,6 +1788,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys", ] @@ -1417,6 +1828,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + [[package]] name = "tower" version = "0.5.3" @@ -1536,9 +1977,9 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "rand", + "rand 0.9.2", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -1548,6 +1989,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1720,6 +2172,28 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1788,6 +2262,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1905,6 +2397,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zbus_systemd" +version = "0.26000.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c8310483ff4a77492d04a90bef30f40938059b4a4b4f7e456dda335d816cb6" +dependencies = [ + "serde", + "zbus", +] + [[package]] name = "zerocopy" version = "0.8.47" @@ -1984,3 +2548,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 0.7.15", +] diff --git a/justfile b/justfile index 4662d4e..fbb61dd 100644 --- a/justfile +++ b/justfile @@ -8,6 +8,12 @@ build: build_agent build_agent: cargo build --release -p odorobo-agent +build_agent_debug: + cargo build -p odorobo-agent + +debug: build_agent_debug + sudo target/debug/odorobo-agent + install: install_script install_unit install_agent install_script: @@ -18,5 +24,8 @@ install_script: install_unit: install -Dm644 systemd/odorobo-ch@.service {{ DATADIR }}/systemd/user/odorobo-ch@.service -install_agent: +install_agent: build_agent install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent + +install_agent_debug: + install -Dm755 target/debug/odorobo-agent {{ BINDIR }}/odorobo-agent diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index d09db0d..1ca5b08 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -23,3 +23,7 @@ thiserror = "2.0.18" futures-util = "0.3.31" libc = "0.2" tower-http = { workspace = true, features = ["trace"] } +random-port = "0.1.1" +zbus = "5.14.0" +zbus_systemd = { version = "0.26000.0", features = ["machine1", "systemd1", "zbus-async-tokio"] } + diff --git a/odorobo-agent/src/api/ch.rs b/odorobo-agent/src/api/ch.rs index 0ca8c23..6a081cd 100644 --- a/odorobo-agent/src/api/ch.rs +++ b/odorobo-agent/src/api/ch.rs @@ -12,13 +12,13 @@ pub async fn passthrough( Path((vmid, path)): Path<(String, String)>, request: Request, ) -> Result { - let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound(vmid.clone()))?; + let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.clone() })?; let (mut parts, body) = request.into_parts(); let body = body .collect() .await - .map_err(|_| ApiError::PassthroughFailed)? + .map_err(|e| ApiError::PassthroughFailed { msg: e.to_string() })? .to_bytes(); let path_and_query = match parts.uri.query() { @@ -27,12 +27,12 @@ pub async fn passthrough( }; parts.uri = path_and_query .parse() - .map_err(|_| ApiError::PassthroughFailed)?; + .map_err(|e| ApiError::PassthroughFailed { msg: format!("URI parse error: {}", e) })?; let response = vm .call_request(hyper::Request::from_parts(parts, body)) .await - .map_err(|_| ApiError::PassthroughFailed)?; + .map_err(|e| ApiError::PassthroughFailed { msg: e.to_string() })?; let (parts, body) = response.into_parts(); Ok(Response::from_parts(parts, Body::from(body))) diff --git a/odorobo-agent/src/api/console.rs b/odorobo-agent/src/api/console.rs index 9bd7ca8..de9dd2c 100644 --- a/odorobo-agent/src/api/console.rs +++ b/odorobo-agent/src/api/console.rs @@ -21,8 +21,8 @@ pub async fn console_stream( ws: WebSocketUpgrade, ) -> Result { let vmid = vmid.0; - let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound(vmid.clone()))?; - let console = vm.open_console().await.map_err(|_| ApiError::ConsoleFailed)?; + let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.clone() })?; + let console = vm.open_console().await.map_err(|e| ApiError::ConsoleFailed { msg: e.to_string() })?; Ok(ws.on_upgrade(move |socket| proxy_console_socket(vmid, socket, console))) } diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index 01ed980..9a523da 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -1,29 +1,36 @@ -use axum_responses::HttpError; -use thiserror::Error; +use axum_responses::{HttpError, thiserror::Error}; #[derive(Debug, Error, HttpError)] pub enum ApiError { - #[error("VM not found: {0}")] - #[http(code = 404)] - VmNotFound(String), + #[error("VM not found: {vmid}")] + #[http(code = 404, message = vmid)] + VmNotFound { vmid: String }, - #[error("Failed to get VM info")] - #[http(code = 500)] - VmInfoFailed, + #[error("Failed to get VM info: {msg}")] + #[http(code = 500, message = msg)] + VmInfoFailed { msg: String }, - #[error("Failed to list VMs")] - #[http(code = 500)] - ListFailed, + #[error("Failed to list VMs: {msg}")] + #[http(code = 500, message = msg)] + ListFailed { msg: String }, - #[error("Failed to create VM")] - #[http(code = 500)] - CreateFailed, + #[error("Failed to create VM: {msg}")] + #[http(code = 500, message = msg)] + CreateFailed { msg: String }, - #[error("Failed to open VM console")] - #[http(code = 500)] - ConsoleFailed, + #[error("Failed to open VM console: {msg}")] + #[http(code = 500, message = msg)] + ConsoleFailed { msg: String }, - #[error("Failed to proxy Cloud Hypervisor API request")] - #[http(code = 500)] - PassthroughFailed, + #[error("Failed to proxy Cloud Hypervisor API request: {msg}")] + #[http(code = 500, message = msg)] + PassthroughFailed { msg: String }, + + #[error("Failed to delete VM configuration: configuration: {msg}")] + #[http(code = 500, message = msg)] + DeleteConfigFailed { msg: String }, + + #[error("Failed to delete VM configuration: configuration: {msg}")] + #[http(code = 500, message = msg)] + CreateConfigFailed { msg: String }, } diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index f58715a..4be41d5 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -2,11 +2,15 @@ mod ch; mod console; mod error; -use tower_http::trace::TraceLayer; +use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; mod vm; pub fn router() -> axum::Router<()> { axum::Router::new() - .layer(TraceLayer::new_for_http()) + .layer( + TraceLayer::new_for_http() + .on_request(DefaultOnRequest::new()) + .on_response(DefaultOnResponse::new()) + ) .route("/", axum::routing::get(root)) .route("/health", axum::routing::get(health)) .nest("/vms", vm::router()) diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 00b40a6..75535e4 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -3,8 +3,9 @@ use axum::{ extract::{Path, Query}, }; use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use stable_eyre::Result; +use tracing::{error, trace, warn}; use super::error::ApiError; use crate::state::VMInstance; @@ -12,7 +13,14 @@ use crate::state::VMInstance; pub fn router() -> axum::Router<()> { axum::Router::new() .route("/", axum::routing::get(list_vms)) - .route("/", axum::routing::put(create_vm)) + .route("/{vmid}", axum::routing::put(spawn_vm)) + .route("/{vmid}/config", axum::routing::put(create_vm_config)) + .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) + .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) + .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) + .route("/{vmid}/boot", axum::routing::put(boot_vm)) + .route("/{vmid}/pause", axum::routing::put(pause_vm)) + .route("/{vmid}/resume", axum::routing::put(resume_vm)) .route("/{vmid}", axum::routing::get(vm_info)) .route("/{vmid}/ping", axum::routing::get(ping_vm)) .route("/{vmid}", axum::routing::delete(destroy_vm)) @@ -28,13 +36,15 @@ pub fn router() -> axum::Router<()> { /// Lists all VMs by their IDs async fn list_vms() -> Result>, ApiError> { - let vms = VMInstance::list().map_err(|_| ApiError::ListFailed)?; + let vms = VMInstance::list().map_err(|e| ApiError::ListFailed { msg: e.to_string() })?; Ok(Json(vms.into_iter().map(|i| i.id).collect())) } /// Helper function to get a VM instance by ID, returning an error if not found fn get_vm(vmid: &str) -> Result { - VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound(vmid.to_string())) + VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound { + vmid: vmid.to_string(), + }) } /// Gets detailed information about a specific VM @@ -43,14 +53,20 @@ async fn vm_info( ) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - let info = vm.info().await.map_err(|_| ApiError::VmInfoFailed)?; + let info = vm + .info() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; Ok(Json(info)) } /// Pings the VMM to check if it's running async fn ping_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - let res = vm.ping().await.map_err(|_| ApiError::VmInfoFailed)?; + let res = vm + .ping() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; Ok(Json(res)) } @@ -63,29 +79,144 @@ pub struct CreateVmQuery { /// Defaults to `false`. pub boot: bool, } +#[derive(Debug, Deserialize, Serialize)] +pub struct VmSpawnResponse { + pub info: Option, + pub booted: bool, + pub created: bool, +} -/// Create a new VM -async fn create_vm( +/// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately +async fn spawn_vm( vmid: Path, Query(query): Query, - Json(vm_config): Json, -) -> Result, ApiError> { + Json(vm_config): Json>, +) -> Result, ApiError> { + trace!(?vmid, ?query, "Creating VM with config"); + // trace!(?vm_config, "VM config details"); let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); - std::fs::create_dir_all(&runtime_dir).map_err(|_| ApiError::CreateFailed)?; + std::fs::create_dir_all(&runtime_dir).map_err(|e| { + error!(error = %e, "Failed to create runtime dir"); + ApiError::CreateFailed { msg: e.to_string() } + })?; + + let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { + error!(error = ?e, "Failed to spawn VM process"); + ApiError::CreateFailed { + msg: format!("{:?}", e), + } + })?; + + if vm_config.is_some() { + trace!(?vmid, "Creating VM with provided config"); + vm.create(vm_config.clone().unwrap(), query.boot) + .await + .map_err(|e| { + error!(error = ?e, "Failed to create VM"); + ApiError::CreateConfigFailed { + msg: format!("{:?}", e), + } + })?; + } else { + trace!(?vmid, "No VM config provided, skipping creation step"); + } - let socket_path = runtime_dir.join("ch.sock"); - let vm = VMInstance::new(&vmid.0, socket_path); + let vm_info = if vm_config.is_some() { + Some(vm.info().await.map_err(|e| { + error!(error = ?e, "Failed to get VM info after creation"); + ApiError::VmInfoFailed { + msg: format!("{:?}", e), + } + })?) + } else { + None + }; + + Ok(Json(VmSpawnResponse { + info: vm_info, + booted: query.boot, + created: true, + })) +} + +async fn create_vm_config( + vmid: Path, + Query(query): Query, + Json(vm_config): Json, +) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; vm.create(vm_config, query.boot) .await - .map_err(|_| ApiError::CreateFailed)?; + .map_err(|e| ApiError::CreateConfigFailed { msg: e.to_string() })?; + Ok(Json(())) +} - let info = vm.info().await.map_err(|_| ApiError::VmInfoFailed)?; - Ok(Json(info)) +async fn delete_vm_config(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.delete_config() + .await + .map_err(|e| ApiError::DeleteConfigFailed { msg: e.to_string() })?; + Ok(Json(())) +} + +/// Forces a VM to shut down immediately, without giving it a chance to gracefully clean up resources. +/// This is equivalent to pulling the power on a physical machine and may lead to data loss or corruption if the VM is running. +/// +/// The VM process itself will still be running until the VMM detects that the VM has stopped, +/// not fully cleaning up resources. +async fn shutdown_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.shutdown() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + Ok(Json(())) +} + +/// Sends an ACPI shutdown signal to the VM, allowing it to gracefully shut down and clean up resources. +/// +/// With the systemd provisioner, this will also fully clean up resources and destroy the VM instance entirely, +/// allowing them to be re-provisioned again on any other node (if running in a cluster) +async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.acpi_power_button() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + Ok(Json(())) +} + +/// Boots a VM that has been created but not yet started. If the VM is already running, this will return an error. +async fn boot_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.boot() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + Ok(Json(())) +} + +/// Suspends a running VM, pausing all activity until +/// it is resumed again. +async fn pause_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.pause() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + Ok(Json(())) +} + +/// Resumes a paused VM, allowing it to continue running from where it left off. +async fn resume_vm(vmid: Path) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.resume() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + Ok(Json(())) } /// Destroys a VM, stopping it if it's running and cleaning up resources async fn destroy_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.destroy().await.map_err(|_| ApiError::VmInfoFailed)?; + vm.destroy() + .await + .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; Ok(Json(())) } diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 8acd740..aa1fb6c 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -6,6 +6,8 @@ async fn main() -> Result<()> { stable_eyre::install()?; tracing_subscriber::fmt() .with_max_level(tracing::Level::TRACE) + .with_file(true) + .with_line_number(true) .init(); tracing::info!("Starting odorobo-agent..."); diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index f51ccd7..9ab8be4 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -3,8 +3,8 @@ use cloud_hypervisor_client::{ apis::DefaultApi, models::{self, VmInfo, VmmPingResponse}, }; -use hyper::{Request, Response, body::Bytes}; use hyper::Method; +use hyper::{Request, Response, body::Bytes}; use serde_json::Value; use stable_eyre::{ Result, @@ -15,7 +15,7 @@ use std::{ fs::OpenOptions, path::{Path, PathBuf}, }; -use tracing::{debug, info, warn}; +use tracing::{debug, info, trace, warn}; use super::api::{call, call_request}; use super::transform::apply_builtin_transforms; @@ -46,6 +46,74 @@ impl VMInstance { Self::list().ok()?.into_iter().find(|i| i.id == vmid) } + pub async fn boot(&self) -> Result<()> { + self.conn() + .boot_vm() + .await + .wrap_err(eyre!("Failed to boot VM {}", self.vm_id())) + } + + pub async fn pause(&self) -> Result<()> { + self.conn() + .pause_vm() + .await + .wrap_err(eyre!("Failed to pause VM {}", self.vm_id())) + } + + pub async fn resume(&self) -> Result<()> { + self.conn() + .resume_vm() + .await + .wrap_err(eyre!("Failed to resume VM {}", self.vm_id())) + } + + #[tracing::instrument] + pub async fn send_migration(&self, dest: &str) -> Result<()> { + let conn = self.conn(); + trace!(destination = dest, "Sending migration command to VM"); + + let send_migration_data = models::SendMigrationData { + destination_url: dest.to_string(), + ..Default::default() + }; + + conn.vm_send_migration_put(send_migration_data) + .await + .wrap_err(eyre!( + "Failed to send migration command for {}", + self.vm_id() + )) + } + + #[tracing::instrument] + pub async fn prepare_migration(&self) -> Result<()> { + let conn = self.conn(); + trace!("Preparing VM for migration"); + + let rand_port = random_port::PortPicker::new().pick()?; + + trace!(port = rand_port, "Selected random port for migration"); + + let reciever_uri = format!("tcp:0.0.0.0:{}", rand_port); + + let receive_migration_data = models::ReceiveMigrationData { + receiver_url: reciever_uri.clone(), + ..Default::default() + }; + + conn.vm_receive_migration_put(receive_migration_data) + .await + .wrap_err(eyre!("Failed to prepare VM for migration {}", self.vm_id()))?; + + info!( + vm_id = self.vm_id(), + uri = ?reciever_uri, + "VM prepared for migration, receiver listening" + ); + + Ok(()) + } + pub fn runtime_root() -> PathBuf { Self::configured_runtime_root().join(VMS_DIR_NAME) } @@ -111,6 +179,13 @@ impl VMInstance { .wrap_err(eyre!("Failed to shutdown VM {}", self.vm_id())) } + pub async fn acpi_power_button(&self) -> Result<()> { + self.conn().power_button_vm().await.wrap_err(eyre!( + "Failed to send ACPI power button event to VM {}", + self.vm_id() + )) + } + pub async fn ping(&self) -> Result { self.conn() .vmm_ping_get() @@ -144,7 +219,7 @@ impl VMInstance { /// Spawn a new CH process and create a VMInstance for it. /// /// Waits for the socket to become available (polls up to ~30 seconds). - /// The caller should handle config persistence and VM creation. + /// Calls a backend to handle the actual CH process spawning - typically a systemd unit pub async fn spawn(id: &str) -> Result { info!( vm_id = id, @@ -152,10 +227,14 @@ impl VMInstance { "Spawning CH process for new VM" ); + let provisioner = super::provisioning::default_provisioner(); + provisioner.start_instance(id).await?; + let instance = Self::new(id, Self::runtime_dir_for(id).join(SOCKET_FILE_NAME)); const MAX_ATTEMPTS: u32 = 31; for attempt in 0..MAX_ATTEMPTS { + trace!(vm_id = id, attempt, "Checking if CH socket is available"); if instance.conn().vmm_ping_get().await.is_ok() { debug!(vm_id = id, "CH socket available"); return Ok(instance); @@ -174,8 +253,6 @@ impl VMInstance { } /// Gracefully shutdown the VM and VMM, then clean up runtime state. - /// - /// Does NOT stop the CH deployment - that should be handled by the orchestrator. pub async fn destroy(&self) -> Result<()> { if let Ok(info) = self.info().await { if matches!( @@ -186,12 +263,15 @@ impl VMInstance { self.shutdown().await?; } } + let provisioner = super::provisioning::default_provisioner(); self.conn() .shutdown_vmm() .await .wrap_err(eyre!("Failed to shutdown VMM for {}", self.vm_id()))?; + provisioner.stop_instance(self.vm_id()).await?; + if let Err(err) = self.purge_instance_data() { warn!( vm_id = self.vm_id(), @@ -241,10 +321,15 @@ impl VMInstance { /// 1. Creates the VM via CH API /// 2. Boots the VM (if boot is true) pub async fn create(&self, config: models::VmConfig, boot: bool) -> Result<()> { + trace!(vm_id = self.vm_id(), "Creating VM with provided config"); let mut config = config; + trace!(vm_id = self.vm_id(), "Applying config transforms"); apply_builtin_transforms(&mut config).wrap_err("Failed to apply config transforms")?; + trace!(vm_id = self.vm_id(), "Saving config to runtime dir"); self.save_config(&config)?; + + trace!(vm_id = self.vm_id(), "Creating VM via CH API"); self.conn() .create_vm(config) .await @@ -261,6 +346,20 @@ impl VMInstance { Ok(()) } + pub async fn delete_config(&self) -> Result<()> { + let conn = self.conn(); + trace!(vm_id = self.vm_id(), "Deleting VM via CH API"); + conn.delete_vm() + .await + .wrap_err(eyre!("Failed to delete VM {}", self.vm_id()))?; + let config_path = self.config_path(); + if config_path.exists() { + fs::remove_file(config_path) + .wrap_err(eyre!("Failed to remove config file for {}", self.vm_id()))?; + } + Ok(()) + } + /// Call a custom CH API path with an explicit HTTP method. /// /// For debugging: lets you hit any CH API endpoint directly. @@ -281,7 +380,9 @@ impl VMInstance { /// /// Scans runtime root for directories with valid sockets. pub fn list() -> Result> { - Ok(fs::read_dir(Self::runtime_root())? + let root = Self::runtime_root(); + fs::create_dir_all(&root)?; + Ok(fs::read_dir(root)? .filter_map(|entry| { entry.ok().and_then(|entry| { if !entry.file_type().ok()?.is_dir() { diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index be9755b..26381b8 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -5,8 +5,9 @@ mod api; mod instance; +mod provisioning; mod transform; pub use api::{call, call_request}; pub use instance::{CONFIG_FILE_NAME, ConsoleStream, VMInstance, VMS_DIR_NAME}; -pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; +// pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; diff --git a/odorobo-agent/src/state/provisioning/hooks.rs b/odorobo-agent/src/state/provisioning/hooks.rs new file mode 100644 index 0000000..c1c4890 --- /dev/null +++ b/odorobo-agent/src/state/provisioning/hooks.rs @@ -0,0 +1 @@ +//! Hooks for provisioning state. diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs new file mode 100644 index 0000000..cdfa990 --- /dev/null +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -0,0 +1,40 @@ +//! Module for provisioning CH instances for odorobo agent. +//! +//! Provides helper functions that calls the necessary hooks and various methods to start a +//! Cloud Hypervisor process for a given instance +mod hooks; +mod systemd; +use stable_eyre::Result; +use tracing::info; + +use crate::state::provisioning::systemd::SystemdUnitProvisioner; + +pub struct VMProvisioner { + backend: B, +} + +impl VMProvisioner { + // todo: call hooks here + #[tracing::instrument(skip(self))] + pub async fn start_instance(&self, vmid: &str) -> Result { + info!(?vmid, "Starting instance"); + self.backend.start_instance(vmid).await + } + + #[tracing::instrument(skip(self))] + pub async fn stop_instance(&self, vmid: &str) -> Result<()> { + info!(?vmid, "Stopping instance"); + self.backend.stop_instance(vmid).await + } +} + +pub trait VMProvisionerBackend: Send + Sync { + async fn start_instance(&self, vmid: &str) -> Result; + async fn stop_instance(&self, vmid: &str) -> Result<()>; +} + +pub fn default_provisioner() -> VMProvisioner { + VMProvisioner { + backend: SystemdUnitProvisioner, + } +} diff --git a/odorobo-agent/src/state/provisioning/systemd/mod.rs b/odorobo-agent/src/state/provisioning/systemd/mod.rs new file mode 100644 index 0000000..91c0fb0 --- /dev/null +++ b/odorobo-agent/src/state/provisioning/systemd/mod.rs @@ -0,0 +1,20 @@ +mod proxy; +use super::VMProvisionerBackend; +use proxy::{start_instance, stop_instance}; +use stable_eyre::{Result, eyre::WrapErr}; + +pub struct SystemdUnitProvisioner; + +impl VMProvisionerBackend for SystemdUnitProvisioner { + async fn start_instance(&self, vmid: &str) -> Result { + start_instance(vmid) + .await + .wrap_err_with(|| format!("Failed to start instance {vmid} with systemd unit")) + } + + async fn stop_instance(&self, vmid: &str) -> Result<()> { + stop_instance(vmid) + .await + .wrap_err_with(|| format!("Failed to stop instance {vmid} with systemd unit")) + } +} diff --git a/odorobo-agent/src/state/provisioning/systemd/proxy.rs b/odorobo-agent/src/state/provisioning/systemd/proxy.rs new file mode 100644 index 0000000..6f68ae8 --- /dev/null +++ b/odorobo-agent/src/state/provisioning/systemd/proxy.rs @@ -0,0 +1,96 @@ +//! systemd provisioning module for odorobo agent + +/// This module provides functions for provisioning CH instances for odorobo agent +/// using systemd. +/// +/// All it should do is simply just start and stop systemd services with the correct parameters +use stable_eyre::{Result, eyre::WrapErr}; +use tracing::trace; +use zbus::Connection; +use zbus_systemd::systemd1::{ManagerProxy, ServiceProxy}; + +/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name +fn instance_unit_name(vmid: &str) -> String { + format!("odorobo-ch@{vmid}.service") +} + +async fn system_connection() -> Result { + Connection::system() + .await + .wrap_err("Failed to connect to system D-Bus") +} + +pub async fn manager_proxy(connection: &Connection) -> Result> { + ManagerProxy::new(connection) + .await + .wrap_err("Failed to create systemd manager proxy") +} + +pub async fn service_proxy<'a>( + connection: &'a Connection, + unit_name: &str, +) -> Result> { + let manager = manager_proxy(connection).await?; + let unit_path = manager + .load_unit(unit_name.to_string()) + .await + .wrap_err_with(|| format!("Failed to load systemd unit {unit_name}"))?; + + ServiceProxy::builder(connection) + .path(unit_path) + .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? + .build() + .await + .wrap_err_with(|| format!("Failed to create service proxy for systemd unit {unit_name}")) +} + +#[tracing::instrument] +pub async fn start_instance(vmid: &str) -> Result { + let connection = system_connection().await?; + let manager = manager_proxy(&connection).await?; + let unit_name = instance_unit_name(vmid); + trace!(?unit_name, "Starting systemd unit"); + + manager + .start_unit(unit_name.clone(), "replace".into()) + .await + .wrap_err_with(|| format!("Failed to start systemd unit {unit_name}"))?; + + let service = service_proxy(&connection, &unit_name).await?; + let pid = service + .main_pid() + .await + .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; + + Ok(pid as i32) +} + +#[tracing::instrument] +pub async fn stop_instance(vmid: &str) -> Result<()> { + trace!(?vmid, "Stopping instance"); + let connection = system_connection().await?; + let manager = manager_proxy(&connection).await?; + let unit_name = instance_unit_name(vmid); + + manager + .stop_unit(unit_name.clone(), "replace".into()) + .await + .wrap_err_with(|| format!("Failed to stop systemd unit {unit_name}"))?; + + Ok(()) +} + +#[tracing::instrument] +pub async fn get_main_pid(vmid: &str) -> Result { + trace!(?vmid, "Getting MainPID for instance"); + let connection = system_connection().await?; + let unit_name = instance_unit_name(vmid); + let service = service_proxy(&connection, &unit_name).await?; + + let pid = service + .main_pid() + .await + .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; + + Ok(pid as i32) +} From 52385f9e3d1a96aab786f9e43118bb340c8f738d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 14:56:53 +0700 Subject: [PATCH 014/189] try to validate paths when fetching VMs --- odorobo-agent/src/api/error.rs | 6 +++++- odorobo-agent/src/api/vm.rs | 5 ++++- odorobo-agent/src/state/instance.rs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index 9a523da..970853b 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -1,7 +1,11 @@ -use axum_responses::{HttpError, thiserror::Error}; +use axum_responses::{thiserror::Error, HttpError}; #[derive(Debug, Error, HttpError)] pub enum ApiError { + #[error("Invalid VM ID: {msg}")] + #[http(code = 400, message = msg)] + InvalidVmId { msg: String }, + #[error("VM not found: {vmid}")] #[http(code = 404, message = vmid)] VmNotFound { vmid: String }, diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 75535e4..edc26ef 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -5,7 +5,7 @@ use axum::{ use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; use serde::{Deserialize, Serialize}; use stable_eyre::Result; -use tracing::{error, trace, warn}; +use tracing::{error, trace}; use super::error::ApiError; use crate::state::VMInstance; @@ -42,6 +42,8 @@ async fn list_vms() -> Result>, ApiError> { /// Helper function to get a VM instance by ID, returning an error if not found fn get_vm(vmid: &str) -> Result { + use crate::state::VMInstance; + VMInstance::validate_vmid(vmid).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.to_string(), }) @@ -92,6 +94,7 @@ async fn spawn_vm( Query(query): Query, Json(vm_config): Json>, ) -> Result, ApiError> { + VMInstance::validate_vmid(&vmid.0).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; trace!(?vmid, ?query, "Creating VM with config"); // trace!(?vm_config, "VM config details"); let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 9ab8be4..ce2720d 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -118,6 +118,22 @@ impl VMInstance { Self::configured_runtime_root().join(VMS_DIR_NAME) } + pub fn validate_vmid(vmid: &str) -> Result<()> { + if vmid.is_empty() { + return Err(eyre!("VM ID cannot be empty")); + } + if vmid.contains("..") { + return Err(eyre!("VM ID cannot contain path traversal sequences")); + } + if vmid.contains('/') || vmid.contains('\\') { + return Err(eyre!("VM ID cannot contain path separators")); + } + if vmid.starts_with('.') { + return Err(eyre!("VM ID cannot start with a dot")); + } + Ok(()) + } + pub fn runtime_dir_for(id: &str) -> PathBuf { Self::runtime_root().join(id) } From 375705f0d515b81c3dac5d220582d53888c97845 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 15:57:32 +0700 Subject: [PATCH 015/189] add CLI helper --- Cargo.lock | 954 ++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 +- odorobo-agent/Cargo.toml | 2 +- odoroboctl/Cargo.toml | 14 + odoroboctl/src/cli.rs | 272 +++++++++++ odoroboctl/src/main.rs | 9 + 6 files changed, 1238 insertions(+), 18 deletions(-) create mode 100644 odoroboctl/Cargo.toml create mode 100644 odoroboctl/src/cli.rs create mode 100644 odoroboctl/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1e08f7c..8ad82b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -85,7 +135,7 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -143,7 +193,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -175,6 +225,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -366,15 +438,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -388,6 +474,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cloud-hypervisor-client" version = "0.3.3" @@ -409,6 +535,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -418,6 +569,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -476,6 +647,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -516,7 +702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -583,6 +769,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -701,8 +893,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -712,9 +906,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -867,24 +1063,45 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http 1.4.0", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1052,12 +1269,88 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1113,6 +1406,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.8.4" @@ -1157,7 +1456,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1178,7 +1477,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1226,12 +1525,37 @@ dependencies = [ "zbus_systemd", ] +[[package]] +name = "odoroboctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "reqwest", + "serde", + "serde_json", + "stable-eyre", + "tokio", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1311,7 +1635,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1360,6 +1684,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1461,10 +1841,70 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.27" +name = "reqwest" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -1476,7 +1916,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1497,12 +2012,53 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e27a8b19b835f7aea908818e871f5cc3a5a186550c30773be987e155e8163d8f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1654,7 +2210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1674,6 +2230,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1690,6 +2258,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1702,6 +2273,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1712,7 +2304,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1774,6 +2366,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -1789,7 +2396,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1803,6 +2410,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -1882,9 +2499,12 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", + "futures-util", "http 1.4.0", "http-body", + "iri-string", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", @@ -1997,7 +2617,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2012,6 +2632,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2036,6 +2662,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" @@ -2060,6 +2692,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2106,6 +2748,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -2172,6 +2828,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2188,6 +2873,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2235,6 +2929,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2253,6 +2958,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2262,6 +2994,192 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -2426,7 +3344,7 @@ dependencies = [ "tracing", "uds_windows", "uuid", - "windows-sys", + "windows-sys 0.61.2", "winnow 0.7.15", "zbus_macros", "zbus_names", @@ -2510,6 +3428,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index fa2fef9..f423b3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["odorobo-agent"] +members = ["odorobo-agent", "odoroboctl"] [workspace.dependencies] stable-eyre = "0.2.2" @@ -12,4 +12,5 @@ serde = "1.0" axum = { version = "0.8.8", features = ["ws"] } axum-responses = "0.5.5" axum-extra = "0.12" -tower-http = { version = "0.6", features = ["trace"] } \ No newline at end of file +tower-http = { version = "0.6", features = ["trace"] } +tokio = { version = "1.50.0" } diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 1ca5b08..e7c452f 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -15,7 +15,7 @@ hyper-util = { version = "0.1.20", features = ["client", "client-legacy", "http1 serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } hyperlocal = "0.9.1" -tokio = { version = "1.50.0", features = ["full"] } +tokio = { workspace = true, features = ["full"] } axum = { workspace = true, features = ["ws"] } axum-extra = { workspace = true, features = ["query"] } axum_responses = "0.5.5" diff --git a/odoroboctl/Cargo.toml b/odoroboctl/Cargo.toml new file mode 100644 index 0000000..1c0d961 --- /dev/null +++ b/odoroboctl/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "odoroboctl" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" + +[dependencies] +stable-eyre = { workspace = true } +clap = { version = "4", features = ["derive", "env"] } +tokio = { workspace = true, features = ["full"] } +anyhow = "1" +reqwest = { version = "0.13.2", features = ["json"] } +serde_json = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs new file mode 100644 index 0000000..1dd2ecc --- /dev/null +++ b/odoroboctl/src/cli.rs @@ -0,0 +1,272 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use reqwest::Client; +use stable_eyre::Result; + +#[derive(Parser)] +#[command( + name = "odoroboctl", + about = "Command-line interface for odorobo agent" +)] +pub struct Cli { + /// Address of the odorobo agent API server, e.g. "http://localhost:8890" + #[arg( + long, + env = "ODOROBO_AGENT_ADDR", + default_value = "http://localhost:8890" + )] + pub agent_addr: String, + + /// Directory for storing runtime files for odorobo agent, such as instance and VM state files. + #[arg( + long, + env = "ODOROBO_AGENT_RUNTIME_DIR", + default_value = "/run/odorobo" + )] + pub agent_runtime_dir: String, + + /// Path to the ch-remote binary, used for passing through with `odoroboctl ch-remote ...` + #[arg(long, env = "CH_REMOTE_PATH", default_value = "ch-remote")] + pub ch_remote_path: String, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// List all VMs + List, + /// Get VM information + Info { vmid: String }, + /// Ping VM (check if VMM is running) + Ping { vmid: String }, + /// Spawn (create and start) a new VM + Spawn { + /// VM ID + vmid: String, + + /// Path to the VM config file + /// (in Cloud Hypervisor JSON format) + config: Option, + + /// Boot the VM after creation + #[arg(long)] + boot: bool, + }, + + /// Create a configuration for a new VM, + /// optionally also booting it immediately after creation (if `--boot` is specified). + Create { + /// VM ID + vmid: String, + + /// Path to the VM config file + /// (in Cloud Hypervisor JSON format) + config: PathBuf, + + /// Boot the VM after creation + #[arg(long)] + boot: bool, + }, + /// Delete a VM configuration (without deleting the VM instance itself) + Delete { vmid: String }, + /// Boot a VM + Boot { vmid: String }, + /// Pause a running VM + Pause { vmid: String }, + /// Resume a paused VM + Resume { vmid: String }, + /// Shutdown a VM gracefully + Shutdown { vmid: String }, + /// Send ACPI power button event to VM + AcpiShutdown { vmid: String }, + /// Destroy (delete) a VM + Destroy { vmid: String }, + /// Pass-through to Cloud Hypervisor CLI + ChRemote { + vmid: String, + /// Any arbitrary args to pass through to ch-remote, e.g. `odoroboctl ch-remote myvm ping` + #[arg(last = true)] + args: Vec, + }, +} + +pub async fn run_command(cli: Cli) -> Result<()> { + let client = Client::new(); + let base_url = format!("{}/vms", cli.agent_addr); + + match cli.command { + Command::List => { + let response = client.get(&base_url).send().await?; + + if response.status().is_success() { + let vms: Vec = response.json().await?; + println!("VMs:"); + for vm in vms { + println!("- {}", vm); + } + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Info { vmid } => { + let url = format!("{}/{}", base_url, vmid); + let response = client.get(&url).send().await?; + + if response.status().is_success() { + println!("{}", response.text().await?); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Ping { vmid } => { + let url = format!("{}/{}/ping", base_url, vmid); + let response = client.get(&url).send().await?; + + if response.status().is_success() { + println!("{}", response.text().await?); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Spawn { vmid, config, boot } => { + let url = format!("{base_url}/{vmid}?boot={boot}"); + + let config_content = if let Some(config_path) = config { + Some(std::fs::read_to_string(&config_path)?) + } else { + None + }; + + let response = client + .put(&url) + .header("Content-Type", "application/json") + .body(config_content.unwrap_or_default()) + .send() + .await?; + + if response.status().is_success() { + println!("{}", response.text().await?); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Create { vmid, config, boot } => { + let url = format!("{base_url}/{vmid}/config?boot={boot}"); + let config_content = std::fs::read_to_string(&config)?; + let response = client + .put(&url) + .header("Content-Type", "application/json") + .body(config_content) + .send() + .await?; + + if response.status().is_success() { + println!("VM created successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Delete { vmid } => { + let url = format!("{base_url}/{vmid}/config"); + let response = client.delete(&url).send().await?; + + if response.status().is_success() { + println!("VM configuration deleted successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Boot { vmid } => { + let url = format!("{}/{}/boot", base_url, vmid); + let response = client.put(&url).send().await?; + + if response.status().is_success() { + println!("VM booted successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Pause { vmid } => { + let url = format!("{}/{}/pause", base_url, vmid); + let response = client.put(&url).send().await?; + + if response.status().is_success() { + println!("VM paused successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Resume { vmid } => { + let url = format!("{}/{}/resume", base_url, vmid); + let response = client.put(&url).send().await?; + + if response.status().is_success() { + println!("VM resumed successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Shutdown { vmid } => { + let url = format!("{}/{}/shutdown", base_url, vmid); + let response = client.put(&url).send().await?; + + if response.status().is_success() { + println!("VM shutdown initiated"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::AcpiShutdown { vmid } => { + let url = format!("{}/{}/acpi_shutdown", base_url, vmid); + let response = client.put(&url).send().await?; + + if response.status().is_success() { + println!("ACPI shutdown signal sent"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::Destroy { vmid } => { + let url = format!("{}/{}", base_url, vmid); + let response = client.delete(&url).send().await?; + + if response.status().is_success() { + println!("VM destroyed successfully"); + } else { + eprintln!("Error: {:?}", response.text().await?); + } + } + + Command::ChRemote { vmid, args } => { + let runtime_dir = cli.agent_runtime_dir; + let socket_id = format!("{runtime_dir}/vms/{vmid}/ch.sock"); + let mut child = std::process::Command::new(&cli.ch_remote_path) + .arg("--api-socket") + .arg(socket_id) + .args(args) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn()?; + + let status = child.wait()?; + std::process::exit(status.code().unwrap_or(1)); + } + } + + Ok(()) +} diff --git a/odoroboctl/src/main.rs b/odoroboctl/src/main.rs new file mode 100644 index 0000000..83ecda9 --- /dev/null +++ b/odoroboctl/src/main.rs @@ -0,0 +1,9 @@ +use clap::Parser; + +mod cli; + +#[tokio::main] +async fn main() -> stable_eyre::Result<()> { + let cli = cli::Cli::parse(); + cli::run_command(cli).await +} From 19c91a7e0c6d138f01784883004efa201176d462 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 16:13:39 +0700 Subject: [PATCH 016/189] properly handle spawn --- odorobo-agent/src/api/vm.rs | 20 +++++++++++++++++--- odoroboctl/src/cli.rs | 19 +++++++++---------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index edc26ef..e3a5af9 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -85,16 +85,26 @@ pub struct CreateVmQuery { pub struct VmSpawnResponse { pub info: Option, pub booted: bool, - pub created: bool, + pub created_config: bool, } /// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately async fn spawn_vm( vmid: Path, Query(query): Query, - Json(vm_config): Json>, + vm_config: Option>, ) -> Result, ApiError> { VMInstance::validate_vmid(&vmid.0).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; + let vm_config = vm_config.map(|Json(vm_config)| vm_config); + + // check if VM already exists, if so error out for already existing instance + if VMInstance::get(&vmid.0).is_some() { + error!(vmid = ?vmid, "VM with this ID already exists"); + return Err(ApiError::CreateFailed { + msg: "VM with this ID already exists".to_string(), + }); + } + trace!(?vmid, ?query, "Creating VM with config"); // trace!(?vm_config, "VM config details"); let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); @@ -102,6 +112,7 @@ async fn spawn_vm( error!(error = %e, "Failed to create runtime dir"); ApiError::CreateFailed { msg: e.to_string() } })?; + // trace!(?) let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { error!(error = ?e, "Failed to spawn VM process"); @@ -110,6 +121,7 @@ async fn spawn_vm( } })?; + let mut created = false; if vm_config.is_some() { trace!(?vmid, "Creating VM with provided config"); vm.create(vm_config.clone().unwrap(), query.boot) @@ -120,6 +132,8 @@ async fn spawn_vm( msg: format!("{:?}", e), } })?; + + created = true; } else { trace!(?vmid, "No VM config provided, skipping creation step"); } @@ -138,7 +152,7 @@ async fn spawn_vm( Ok(Json(VmSpawnResponse { info: vm_info, booted: query.boot, - created: true, + created_config: created, })) } diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 1dd2ecc..dbc7cb3 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -137,19 +137,18 @@ pub async fn run_command(cli: Cli) -> Result<()> { Command::Spawn { vmid, config, boot } => { let url = format!("{base_url}/{vmid}?boot={boot}"); - let config_content = if let Some(config_path) = config { - Some(std::fs::read_to_string(&config_path)?) + let response = if let Some(config_path) = config { + let config_content = std::fs::read_to_string(&config_path)?; + client + .put(&url) + .header("Content-Type", "application/json") + .body(config_content) + .send() + .await? } else { - None + client.put(&url).send().await? }; - let response = client - .put(&url) - .header("Content-Type", "application/json") - .body(config_content.unwrap_or_default()) - .send() - .await?; - if response.status().is_success() { println!("{}", response.text().await?); } else { From 5f402a39fbfded1bfe37cd5e5e37e124f043cd7b Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 16:22:46 +0700 Subject: [PATCH 017/189] document basic usage --- README.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51fc6c2..289ccce 100644 --- a/README.md +++ b/README.md @@ -23,5 +23,50 @@ Agents run on each node and manage Cloud Hypervisor instances directly via syste ## Usage -See `docs/console.md` for PTY-over-WebSocket usage and integration details. +Odorobo Agent is meant to be run as a system agent on each bare-metal node (or a VM with nested virtualization support) that will host VMs. The agent manages the lifecycle of Cloud Hypervisor instances on the node it runs on. + +Build the Agent binary with `cargo build --release` and run it on the host machine. The Agent will listen for commands from the Gateway to create, manage, and delete VMs. + +``` +# Build the Agent +cargo build --release +# Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus +# preferrably as the same user as Cloud Hypervisor, see `systemd/odorobo-ch@.service` +# for the CH service template) +sudo ./target/release/odorobo-agent +``` + +For debugging and/or small-scale single-node usage, the CLI is available to interact directly with the agent. + +Install the CLI helper + +``` +cargo install --path odoroboctl +``` + +You can then use `odoroboctl` to directly interact with the Agent, for example to spawn a VM instance + +``` +odoroboctl spawn my-vm +``` + +Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/cloud-hypervisor/blob/main/docs/api.md#create-a-virtual-machine) to the instance, for example with a simple configuration that boots from a disk image + +``` +# the `--boot` flag additionally also tells Cloud Hypervisor to boot the VM after applying the configuration, otherwise it will stay +# in the "Created" state, requiring a separate `odoroboctl boot` call to start it. +odoroboctl create --boot ./my-vm.json +``` + +Now the VM should be running. You can connect to the VM's virtio-console with: + +``` +screen /run/odorobo/my-vm/console.sock +``` + +There is also a WebSocket proxy that can be used to connect to the console over WebSockets, for example with `websocat`. +See [docs/console.md](docs/console.md) for PTY-over-WebSocket usage and integration details. + +For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API + See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. From 1c6b3a0cbf857fd278a56d8eeb14b8d388b4e032 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 16:24:17 +0700 Subject: [PATCH 018/189] CLI is implemented --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 289ccce..3d7bdcc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ disk images can be backed by distributed storage for full portability, or kept n - **Agent**: A lightweight agent that handles VM lifecycle management, including creation, deletion, and state management. - **Gateway**: A central controller that orchestrates the deployment and management of VMs, reconciling and handling migration across nodes (unimplemented). -- **CLI**: Debugging and management command-line interface for interacting with the platform, not intended for end-users but useful for developers and operators (unimplemented). +- **CLI**: Debugging and management command-line interface for interacting with the platform, not intended for end-users but useful for developers and operators. The Gateway orchestrates across nodes; Agents run on each node and manage Cloud Hypervisor instances directly via systemd. From f6b7ca3cd654ecee090a55d23b596c0540409ae2 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 16:27:28 +0700 Subject: [PATCH 019/189] `odoroboctl create` requires a positional arg for the VM ID --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d7bdcc..a22ccaa 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/clo ``` # the `--boot` flag additionally also tells Cloud Hypervisor to boot the VM after applying the configuration, otherwise it will stay # in the "Created" state, requiring a separate `odoroboctl boot` call to start it. -odoroboctl create --boot ./my-vm.json +odoroboctl create my-vm --boot ./my-vm.json ``` Now the VM should be running. You can connect to the VM's virtio-console with: From 883b05bc72726c0b10f88172b1b7e4818cff11cb Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 17:09:37 +0700 Subject: [PATCH 020/189] clean up error reporting --- Cargo.lock | 2 + odorobo-agent/src/api/error.rs | 67 +++++++++++++-- odorobo-agent/src/api/vm.rs | 54 ++++-------- odorobo-agent/src/state/instance.rs | 51 ++++++++++-- odorobo-agent/src/state/mod.rs | 2 +- odoroboctl/Cargo.toml | 1 + odoroboctl/src/cli.rs | 125 +++++++++++++--------------- 7 files changed, 186 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ad82b9..0f5db6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1530,6 +1531,7 @@ name = "odoroboctl" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "reqwest", "serde", diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index 970853b..c744c3a 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -1,4 +1,7 @@ use axum_responses::{thiserror::Error, HttpError}; +use stable_eyre::Report; + +use crate::state::ChApiError; #[derive(Debug, Error, HttpError)] pub enum ApiError { @@ -11,16 +14,16 @@ pub enum ApiError { VmNotFound { vmid: String }, #[error("Failed to get VM info: {msg}")] - #[http(code = 500, message = msg)] - VmInfoFailed { msg: String }, + #[http(code = 500, message = msg, errors = errors)] + VmInfoFailed { msg: String, errors: Vec }, #[error("Failed to list VMs: {msg}")] #[http(code = 500, message = msg)] ListFailed { msg: String }, #[error("Failed to create VM: {msg}")] - #[http(code = 500, message = msg)] - CreateFailed { msg: String }, + #[http(code = 500, message = msg, errors = errors)] + CreateFailed { msg: String, errors: Vec }, #[error("Failed to open VM console: {msg}")] #[http(code = 500, message = msg)] @@ -30,11 +33,59 @@ pub enum ApiError { #[http(code = 500, message = msg)] PassthroughFailed { msg: String }, + #[error("Cloud Hypervisor API error: {msg}")] + #[http(code = 502, message = msg, errors = errors)] + ChApiFailed { msg: String, errors: Vec }, + #[error("Failed to delete VM configuration: configuration: {msg}")] - #[http(code = 500, message = msg)] - DeleteConfigFailed { msg: String }, + #[http(code = 500, message = msg, errors = errors)] + DeleteConfigFailed { msg: String, errors: Vec }, #[error("Failed to delete VM configuration: configuration: {msg}")] - #[http(code = 500, message = msg)] - CreateConfigFailed { msg: String }, + #[http(code = 500, message = msg, errors = errors)] + CreateConfigFailed { msg: String, errors: Vec }, +} + +impl ApiError { + fn find_ch_api_error(error: &Report) -> Option<&ChApiError> { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + } + + fn from_report(error: Report, fallback: impl FnOnce(String, Vec) -> Self) -> Self { + if let Some(ch_error) = Self::find_ch_api_error(&error) { + return match ch_error { + ChApiError::Api { status, errors } => fallback( + format!("Cloud Hypervisor API returned status {}", status.as_u16()), + errors.clone(), + ), + ChApiError::Client(_) => fallback(format!("{error:?}"), vec![]), + }; + } + + fallback(format!("{error:?}"), vec![]) + } + + pub fn vm_info(error: Report) -> Self { + Self::from_report(error, |msg, errors| Self::VmInfoFailed { msg, errors }) + } + + pub fn create(error: Report) -> Self { + Self::from_report(error, |msg, errors| Self::CreateFailed { msg, errors }) + } + + pub fn create_config(error: Report) -> Self { + Self::from_report(error, |msg, errors| Self::CreateConfigFailed { + msg, + errors, + }) + } + + pub fn delete_config(error: Report) -> Self { + Self::from_report(error, |msg, errors| Self::DeleteConfigFailed { + msg, + errors, + }) + } } diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index e3a5af9..3e45194 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -55,20 +55,14 @@ async fn vm_info( ) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - let info = vm - .info() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + let info = vm.info().await.map_err(ApiError::vm_info)?; Ok(Json(info)) } /// Pings the VMM to check if it's running async fn ping_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - let res = vm - .ping() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + let res = vm.ping().await.map_err(ApiError::vm_info)?; Ok(Json(res)) } @@ -102,6 +96,7 @@ async fn spawn_vm( error!(vmid = ?vmid, "VM with this ID already exists"); return Err(ApiError::CreateFailed { msg: "VM with this ID already exists".to_string(), + errors: vec![], }); } @@ -110,15 +105,16 @@ async fn spawn_vm( let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); std::fs::create_dir_all(&runtime_dir).map_err(|e| { error!(error = %e, "Failed to create runtime dir"); - ApiError::CreateFailed { msg: e.to_string() } + ApiError::CreateFailed { + msg: e.to_string(), + errors: vec![], + } })?; // trace!(?) let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { error!(error = ?e, "Failed to spawn VM process"); - ApiError::CreateFailed { - msg: format!("{:?}", e), - } + ApiError::create(e) })?; let mut created = false; @@ -128,9 +124,7 @@ async fn spawn_vm( .await .map_err(|e| { error!(error = ?e, "Failed to create VM"); - ApiError::CreateConfigFailed { - msg: format!("{:?}", e), - } + ApiError::create_config(e) })?; created = true; @@ -141,9 +135,7 @@ async fn spawn_vm( let vm_info = if vm_config.is_some() { Some(vm.info().await.map_err(|e| { error!(error = ?e, "Failed to get VM info after creation"); - ApiError::VmInfoFailed { - msg: format!("{:?}", e), - } + ApiError::vm_info(e) })?) } else { None @@ -164,7 +156,7 @@ async fn create_vm_config( let vm = get_vm(&vmid.0)?; vm.create(vm_config, query.boot) .await - .map_err(|e| ApiError::CreateConfigFailed { msg: e.to_string() })?; + .map_err(ApiError::create_config)?; Ok(Json(())) } @@ -172,7 +164,7 @@ async fn delete_vm_config(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; vm.delete_config() .await - .map_err(|e| ApiError::DeleteConfigFailed { msg: e.to_string() })?; + .map_err(ApiError::delete_config)?; Ok(Json(())) } @@ -183,9 +175,7 @@ async fn delete_vm_config(vmid: Path) -> Result, ApiError> { /// not fully cleaning up resources. async fn shutdown_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.shutdown() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + vm.shutdown().await.map_err(ApiError::vm_info)?; Ok(Json(())) } @@ -197,16 +187,14 @@ async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; vm.acpi_power_button() .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + .map_err(ApiError::vm_info)?; Ok(Json(())) } /// Boots a VM that has been created but not yet started. If the VM is already running, this will return an error. async fn boot_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.boot() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + vm.boot().await.map_err(ApiError::vm_info)?; Ok(Json(())) } @@ -214,26 +202,20 @@ async fn boot_vm(vmid: Path) -> Result, ApiError> { /// it is resumed again. async fn pause_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.pause() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + vm.pause().await.map_err(ApiError::vm_info)?; Ok(Json(())) } /// Resumes a paused VM, allowing it to continue running from where it left off. async fn resume_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.resume() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + vm.resume().await.map_err(ApiError::vm_info)?; Ok(Json(())) } /// Destroys a VM, stopping it if it's running and cleaning up resources async fn destroy_vm(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.destroy() - .await - .map_err(|e| ApiError::VmInfoFailed { msg: e.to_string() })?; + vm.destroy().await.map_err(ApiError::vm_info)?; Ok(Json(())) } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index ce2720d..8bd2eef 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -1,6 +1,6 @@ use cloud_hypervisor_client::{ SocketBasedApiClient, - apis::DefaultApi, + apis::{DefaultApi, Error as ChClientError}, models::{self, VmInfo, VmmPingResponse}, }; use hyper::Method; @@ -15,6 +15,7 @@ use std::{ fs::OpenOptions, path::{Path, PathBuf}, }; +use thiserror::Error; use tracing::{debug, info, trace, warn}; use super::api::{call, call_request}; @@ -34,6 +35,30 @@ pub struct VMInstance { pub ch_socket_path: PathBuf, } +#[derive(Debug, Error)] +pub enum ChApiError { + #[error("Cloud Hypervisor API error {status}: {errors:?}")] + Api { + status: hyper::StatusCode, + errors: Vec, + }, + #[error(transparent)] + Client(ChClientError), +} + +impl From for ChApiError { + fn from(error: ChClientError) -> Self { + match error { + ChClientError::Api(api) => Self::Api { + status: api.code, + errors: serde_json::from_str::>(&api.body) + .unwrap_or_else(|_| vec![api.body]), + }, + other => Self::Client(other), + } + } +} + impl VMInstance { pub fn new(id: &str, ch_socket_path: PathBuf) -> Self { Self { @@ -50,6 +75,7 @@ impl VMInstance { self.conn() .boot_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to boot VM {}", self.vm_id())) } @@ -57,6 +83,7 @@ impl VMInstance { self.conn() .pause_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to pause VM {}", self.vm_id())) } @@ -64,6 +91,7 @@ impl VMInstance { self.conn() .resume_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to resume VM {}", self.vm_id())) } @@ -79,6 +107,7 @@ impl VMInstance { conn.vm_send_migration_put(send_migration_data) .await + .map_err(ChApiError::from) .wrap_err(eyre!( "Failed to send migration command for {}", self.vm_id() @@ -103,6 +132,7 @@ impl VMInstance { conn.vm_receive_migration_put(receive_migration_data) .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to prepare VM for migration {}", self.vm_id()))?; info!( @@ -185,6 +215,7 @@ impl VMInstance { self.conn() .vm_info_get() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to get VM info for {}", self.vm_id())) } @@ -192,20 +223,26 @@ impl VMInstance { self.conn() .shutdown_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to shutdown VM {}", self.vm_id())) } pub async fn acpi_power_button(&self) -> Result<()> { - self.conn().power_button_vm().await.wrap_err(eyre!( - "Failed to send ACPI power button event to VM {}", - self.vm_id() - )) + self.conn() + .power_button_vm() + .await + .map_err(ChApiError::from) + .wrap_err(eyre!( + "Failed to send ACPI power button event to VM {}", + self.vm_id() + )) } pub async fn ping(&self) -> Result { self.conn() .vmm_ping_get() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to ping VM {}", self.vm_id())) } @@ -284,6 +321,7 @@ impl VMInstance { self.conn() .shutdown_vmm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to shutdown VMM for {}", self.vm_id()))?; provisioner.stop_instance(self.vm_id()).await?; @@ -349,6 +387,7 @@ impl VMInstance { self.conn() .create_vm(config) .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to create VM {}", self.vm_id()))?; if boot { @@ -356,6 +395,7 @@ impl VMInstance { self.conn() .boot_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; } info!(vm_id = self.vm_id(), "VM created and booted"); @@ -367,6 +407,7 @@ impl VMInstance { trace!(vm_id = self.vm_id(), "Deleting VM via CH API"); conn.delete_vm() .await + .map_err(ChApiError::from) .wrap_err(eyre!("Failed to delete VM {}", self.vm_id()))?; let config_path = self.config_path(); if config_path.exists() { diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 26381b8..669f652 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -9,5 +9,5 @@ mod provisioning; mod transform; pub use api::{call, call_request}; -pub use instance::{CONFIG_FILE_NAME, ConsoleStream, VMInstance, VMS_DIR_NAME}; +pub use instance::{CONFIG_FILE_NAME, ChApiError, ConsoleStream, VMInstance, VMS_DIR_NAME}; // pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; diff --git a/odoroboctl/Cargo.toml b/odoroboctl/Cargo.toml index 1c0d961..3e903bc 100644 --- a/odoroboctl/Cargo.toml +++ b/odoroboctl/Cargo.toml @@ -12,3 +12,4 @@ anyhow = "1" reqwest = { version = "0.13.2", features = ["json"] } serde_json = { workspace = true } serde = { workspace = true, features = ["derive"] } +chrono = { version = "0.4.44", features = ["serde"] } diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index dbc7cb3..55f0f92 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; -use reqwest::Client; +use reqwest::{Client, Response}; +use serde::Deserialize; use stable_eyre::Result; #[derive(Parser)] @@ -93,6 +94,51 @@ pub enum Command { }, } + +// the fields are used using debug printing, so we allow dead code warnings +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct APIError { + pub code: u16, + pub message: String, + pub errors: Option>, + pub success: bool, + pub timestamp: chrono::DateTime, +} + +async fn print_api_error(response: Response) -> Result<()> { + let status = response.status(); + let body = response.text().await?; + + if let Ok(error) = serde_json::from_str::(&body) { + eprintln!("Error (HTTP {}): {:#?}", status.as_u16(), error); + } else { + eprintln!("Error (HTTP {}): {:?}", status.as_u16(), body); + } + + Ok(()) +} + +async fn print_text_response(response: Response) -> Result<()> { + if response.status().is_success() { + println!("{}", response.text().await?); + } else { + print_api_error(response).await?; + } + + Ok(()) +} + +async fn print_message_response(response: Response, success_message: &str) -> Result<()> { + if response.status().is_success() { + println!("{success_message}"); + } else { + print_api_error(response).await?; + } + + Ok(()) +} + pub async fn run_command(cli: Cli) -> Result<()> { let client = Client::new(); let base_url = format!("{}/vms", cli.agent_addr); @@ -108,30 +154,20 @@ pub async fn run_command(cli: Cli) -> Result<()> { println!("- {}", vm); } } else { - eprintln!("Error: {:?}", response.text().await?); + print_api_error(response).await?; } } Command::Info { vmid } => { let url = format!("{}/{}", base_url, vmid); let response = client.get(&url).send().await?; - - if response.status().is_success() { - println!("{}", response.text().await?); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_text_response(response).await?; } Command::Ping { vmid } => { let url = format!("{}/{}/ping", base_url, vmid); let response = client.get(&url).send().await?; - - if response.status().is_success() { - println!("{}", response.text().await?); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_text_response(response).await?; } Command::Spawn { vmid, config, boot } => { @@ -149,11 +185,7 @@ pub async fn run_command(cli: Cli) -> Result<()> { client.put(&url).send().await? }; - if response.status().is_success() { - println!("{}", response.text().await?); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_text_response(response).await?; } Command::Create { vmid, config, boot } => { @@ -166,88 +198,49 @@ pub async fn run_command(cli: Cli) -> Result<()> { .send() .await?; - if response.status().is_success() { - println!("VM created successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM created successfully").await?; } Command::Delete { vmid } => { let url = format!("{base_url}/{vmid}/config"); let response = client.delete(&url).send().await?; - - if response.status().is_success() { - println!("VM configuration deleted successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM configuration deleted successfully").await?; } Command::Boot { vmid } => { let url = format!("{}/{}/boot", base_url, vmid); let response = client.put(&url).send().await?; - - if response.status().is_success() { - println!("VM booted successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM booted successfully").await?; } Command::Pause { vmid } => { let url = format!("{}/{}/pause", base_url, vmid); let response = client.put(&url).send().await?; - - if response.status().is_success() { - println!("VM paused successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM paused successfully").await?; } Command::Resume { vmid } => { let url = format!("{}/{}/resume", base_url, vmid); let response = client.put(&url).send().await?; - - if response.status().is_success() { - println!("VM resumed successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM resumed successfully").await?; } Command::Shutdown { vmid } => { let url = format!("{}/{}/shutdown", base_url, vmid); let response = client.put(&url).send().await?; - - if response.status().is_success() { - println!("VM shutdown initiated"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM shutdown initiated").await?; } Command::AcpiShutdown { vmid } => { let url = format!("{}/{}/acpi_shutdown", base_url, vmid); let response = client.put(&url).send().await?; - - if response.status().is_success() { - println!("ACPI shutdown signal sent"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "ACPI shutdown signal sent").await?; } Command::Destroy { vmid } => { let url = format!("{}/{}", base_url, vmid); let response = client.delete(&url).send().await?; - - if response.status().is_success() { - println!("VM destroyed successfully"); - } else { - eprintln!("Error: {:?}", response.text().await?); - } + print_message_response(response, "VM destroyed successfully").await?; } Command::ChRemote { vmid, args } => { From 3330eecb879b44a13cc28f9cb526fa53231399a7 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 17:30:00 +0700 Subject: [PATCH 021/189] systemd: limit runtime paths --- systemd/odorobo-ch@.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index bc5bb6a..e7df365 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -13,7 +13,7 @@ Type=simple User=odorobo Group=odorobo -RuntimeDirectory=odorobo odorobo/vms odorobo/vms/%i +RuntimeDirectory=odorobo/vms/%i RuntimeDirectoryMode=0750 LogsDirectory=odorobo LogsDirectoryMode=0750 @@ -54,7 +54,7 @@ RestrictRealtime=no AmbientCapabilities=CAP_NET_ADMIN # Paths CH legitimately needs to write -ReadWritePaths=/run/odorobo /var/log/odorobo /etc/odorobo /var/lib/odorobo +ReadWritePaths=/run/odorobo/vms/%i /var/log/odorobo /etc/odorobo /var/lib/odorobo # KVM device access DeviceAllow=/dev/kvm rw From 847b66ec0d62c5db9cecbcbab04fe6c3242874ee Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 29 Mar 2026 18:18:00 +0700 Subject: [PATCH 022/189] implemented provisioning hooks, added systemd-machined integration hook --- odorobo-agent/src/main.rs | 1 + odorobo-agent/src/state/provisioning/hooks.rs | 1 - .../src/state/provisioning/hooks/machined.rs | 75 +++++++++++++++++ .../src/state/provisioning/hooks/mod.rs | 82 +++++++++++++++++++ odorobo-agent/src/state/provisioning/mod.rs | 46 +++++++++-- .../src/state/provisioning/systemd/mod.rs | 2 +- .../src/state/provisioning/systemd/proxy.rs | 30 +++---- odorobo-agent/src/util.rs | 12 +++ 8 files changed, 225 insertions(+), 24 deletions(-) delete mode 100644 odorobo-agent/src/state/provisioning/hooks.rs create mode 100644 odorobo-agent/src/state/provisioning/hooks/machined.rs create mode 100644 odorobo-agent/src/state/provisioning/hooks/mod.rs create mode 100644 odorobo-agent/src/util.rs diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index aa1fb6c..7adc735 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,4 +1,5 @@ mod api; +mod util; mod state; use stable_eyre::Result; #[tokio::main] diff --git a/odorobo-agent/src/state/provisioning/hooks.rs b/odorobo-agent/src/state/provisioning/hooks.rs deleted file mode 100644 index c1c4890..0000000 --- a/odorobo-agent/src/state/provisioning/hooks.rs +++ /dev/null @@ -1 +0,0 @@ -//! Hooks for provisioning state. diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs new file mode 100644 index 0000000..ec5367b --- /dev/null +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -0,0 +1,75 @@ +//! systemd-machined integration hook for odorobo agent + +use crate::state::VMInstance; +use crate::state::provisioning::hooks::{HookFuture, ProvisioningHook}; +use crate::util::zbus_system_connection; +use stable_eyre::eyre::Context; +use stable_eyre::{Result, eyre::eyre}; +use zbus_systemd::machine1::ManagerProxy; + +async fn get_manager_proxy() -> Result> { + let connection = zbus_system_connection().await?; + ManagerProxy::new(&connection) + .await + .wrap_err("Failed to create systemd manager proxy") +} + +pub struct CHMachine { + vmid: String, +} + +impl TryFrom for VMInstance { + type Error = stable_eyre::Report; + + fn try_from(value: CHMachine) -> Result { + VMInstance::get(&value.vmid).ok_or_else(|| { + eyre!( + "Failed to get VMInstance for CHMachine with vmid {}", + value.vmid + ) + .wrap_err("Failed to convert CHMachine to VMInstance") + }) + } +} + +pub const SERVICE_CLASS: &str = "cloud-hypervisor"; + +pub struct CHMachineProvisioningHook; + +impl ProvisioningHook for CHMachineProvisioningHook { + fn after_start(&self, vmid: &str, pid: i32) -> HookFuture<'_> { + let vmid = vmid.to_string(); + Box::pin(async move { + tracing::info!(vmid, pid, "Registering machine with systemd-machined"); + let runtime_dir = VMInstance::runtime_dir_for(&vmid); + let manager = get_manager_proxy().await?; + manager + .register_machine( + vmid, + Vec::new(), + SERVICE_CLASS.to_string(), + "vm".to_string(), + pid as u32, + runtime_dir.display().to_string(), + ) + .await + .wrap_err("Failed to register machine with systemd-machined")?; + + Ok(()) + }) + } + + fn after_stop(&self, vmid: &str) -> HookFuture<'_> { + let vmid = vmid.to_string(); + Box::pin(async move { + tracing::info!(vmid, "Unregistering machine from systemd-machined"); + let manager = get_manager_proxy().await?; + manager + .unregister_machine(vmid) + .await + .wrap_err("Failed to unregister machine from systemd-machined")?; + + Ok(()) + }) + } +} diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs new file mode 100644 index 0000000..44051f0 --- /dev/null +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -0,0 +1,82 @@ +//! Hooks for provisioning state. +use stable_eyre::Result; +use std::{future::Future, pin::Pin}; + +mod machined; + +// hack: our own mini async-trait implementation +// because for some reason using async inside a dynamic dispatch trait +// like this causes the compiler to complain about +// not being able to make vtables for the trait, even though +// rust 2024 should have been supporting this... +// +// and I don't want to pull in the async-trait crate just for this +pub type HookFuture<'a> = Pin> + Send + 'a>>; + +pub trait ProvisioningHook: Send + Sync { + fn before_start(&self, _vmid: &str) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + fn after_start(&self, _vmid: &str, _pid: i32) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + fn before_stop(&self, _vmid: &str) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + fn after_stop(&self, _vmid: &str) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + // fn before_destroy(&self, _vmid: &str) -> HookFuture<'_> { + // Box::pin(async { Ok(()) }) + // } + // fn after_destroy(&self, _vmid: &str) -> HookFuture<'_> { + // Box::pin(async { Ok(()) }) + // } +} + +pub struct HookManager { + hooks: Vec>, +} + +impl HookManager { + pub fn add_hook(mut self, hook: T) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub async fn before_start(&self, vmid: &str) -> Result<()> { + for hook in &self.hooks { + hook.before_start(vmid).await?; + } + Ok(()) + } + + pub async fn after_start(&self, vmid: &str, pid: i32) -> Result<()> { + for hook in &self.hooks { + hook.after_start(vmid, pid).await?; + } + Ok(()) + } + + pub async fn before_stop(&self, vmid: &str) -> Result<()> { + for hook in &self.hooks { + hook.before_stop(vmid).await?; + } + Ok(()) + } + + pub async fn after_stop(&self, vmid: &str) -> Result<()> { + for hook in &self.hooks { + hook.after_stop(vmid).await?; + } + Ok(()) + } +} + +impl Default for HookManager { + fn default() -> Self { + Self { + hooks: vec![Box::new(machined::CHMachineProvisioningHook)], + } + } +} diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs index cdfa990..51c8cbf 100644 --- a/odorobo-agent/src/state/provisioning/mod.rs +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -4,27 +4,61 @@ //! Cloud Hypervisor process for a given instance mod hooks; mod systemd; -use stable_eyre::Result; +use stable_eyre::{Result, eyre::Context}; use tracing::info; +use self::hooks::HookManager; use crate::state::provisioning::systemd::SystemdUnitProvisioner; pub struct VMProvisioner { backend: B, + hooks: HookManager, } impl VMProvisioner { - // todo: call hooks here + pub fn with_hooks(backend: B, hooks: HookManager) -> Self { + Self { backend, hooks } + } + #[tracing::instrument(skip(self))] pub async fn start_instance(&self, vmid: &str) -> Result { info!(?vmid, "Starting instance"); - self.backend.start_instance(vmid).await + self.hooks + .before_start(vmid) + .await + .wrap_err_with(|| format!("before_start hook failed for VM {vmid}"))?; + + let pid = self + .backend + .start_instance(vmid) + .await + .wrap_err_with(|| format!("Failed to start VM instance {vmid}"))?; + + self.hooks + .after_start(vmid, pid) + .await + .wrap_err_with(|| format!("after_start hook failed for VM {vmid}"))?; + + Ok(pid) } #[tracing::instrument(skip(self))] pub async fn stop_instance(&self, vmid: &str) -> Result<()> { info!(?vmid, "Stopping instance"); - self.backend.stop_instance(vmid).await + self.hooks + .before_stop(vmid) + .await + .wrap_err_with(|| format!("before_stop hook failed for VM {vmid}"))?; + + self.backend + .stop_instance(vmid) + .await + .wrap_err_with(|| format!("Failed to stop VM instance {vmid}"))?; + + self.hooks + .after_stop(vmid) + .await + .wrap_err_with(|| format!("after_stop hook failed for VM {vmid}")) } } @@ -34,7 +68,5 @@ pub trait VMProvisionerBackend: Send + Sync { } pub fn default_provisioner() -> VMProvisioner { - VMProvisioner { - backend: SystemdUnitProvisioner, - } + VMProvisioner::with_hooks(SystemdUnitProvisioner, HookManager::default()) } diff --git a/odorobo-agent/src/state/provisioning/systemd/mod.rs b/odorobo-agent/src/state/provisioning/systemd/mod.rs index 91c0fb0..48d20c1 100644 --- a/odorobo-agent/src/state/provisioning/systemd/mod.rs +++ b/odorobo-agent/src/state/provisioning/systemd/mod.rs @@ -1,4 +1,4 @@ -mod proxy; +pub mod proxy; use super::VMProvisionerBackend; use proxy::{start_instance, stop_instance}; use stable_eyre::{Result, eyre::WrapErr}; diff --git a/odorobo-agent/src/state/provisioning/systemd/proxy.rs b/odorobo-agent/src/state/provisioning/systemd/proxy.rs index 6f68ae8..1c58b36 100644 --- a/odorobo-agent/src/state/provisioning/systemd/proxy.rs +++ b/odorobo-agent/src/state/provisioning/systemd/proxy.rs @@ -1,5 +1,6 @@ //! systemd provisioning module for odorobo agent +use crate::util::{systemd_instance_unit_name, zbus_system_connection}; /// This module provides functions for provisioning CH instances for odorobo agent /// using systemd. /// @@ -8,17 +9,16 @@ use stable_eyre::{Result, eyre::WrapErr}; use tracing::trace; use zbus::Connection; use zbus_systemd::systemd1::{ManagerProxy, ServiceProxy}; - /// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name -fn instance_unit_name(vmid: &str) -> String { - format!("odorobo-ch@{vmid}.service") -} +// pub fn instance_unit_name(vmid: &str) -> String { +// format!("odorobo-ch@{vmid}.service") +// } -async fn system_connection() -> Result { - Connection::system() - .await - .wrap_err("Failed to connect to system D-Bus") -} +// async fn system_connection() -> Result { +// Connection::system() +// .await +// .wrap_err("Failed to connect to system D-Bus") +// } pub async fn manager_proxy(connection: &Connection) -> Result> { ManagerProxy::new(connection) @@ -46,9 +46,9 @@ pub async fn service_proxy<'a>( #[tracing::instrument] pub async fn start_instance(vmid: &str) -> Result { - let connection = system_connection().await?; + let connection = zbus_system_connection().await?; let manager = manager_proxy(&connection).await?; - let unit_name = instance_unit_name(vmid); + let unit_name = systemd_instance_unit_name(vmid); trace!(?unit_name, "Starting systemd unit"); manager @@ -68,9 +68,9 @@ pub async fn start_instance(vmid: &str) -> Result { #[tracing::instrument] pub async fn stop_instance(vmid: &str) -> Result<()> { trace!(?vmid, "Stopping instance"); - let connection = system_connection().await?; + let connection = zbus_system_connection().await?; let manager = manager_proxy(&connection).await?; - let unit_name = instance_unit_name(vmid); + let unit_name = systemd_instance_unit_name(vmid); manager .stop_unit(unit_name.clone(), "replace".into()) @@ -83,8 +83,8 @@ pub async fn stop_instance(vmid: &str) -> Result<()> { #[tracing::instrument] pub async fn get_main_pid(vmid: &str) -> Result { trace!(?vmid, "Getting MainPID for instance"); - let connection = system_connection().await?; - let unit_name = instance_unit_name(vmid); + let connection = zbus_system_connection().await?; + let unit_name = systemd_instance_unit_name(vmid); let service = service_proxy(&connection, &unit_name).await?; let pid = service diff --git a/odorobo-agent/src/util.rs b/odorobo-agent/src/util.rs new file mode 100644 index 0000000..610f4be --- /dev/null +++ b/odorobo-agent/src/util.rs @@ -0,0 +1,12 @@ +use zbus::Connection; +use stable_eyre::{Result, eyre::Context}; +/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name +pub fn systemd_instance_unit_name(vmid: &str) -> String { + format!("odorobo-ch@{vmid}.service") +} + +pub async fn zbus_system_connection() -> Result { + Connection::system() + .await + .wrap_err("Failed to connect to system D-Bus") +} From aa87a907d30b2c0953e32a635335fbe360694179 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 08:11:30 +0700 Subject: [PATCH 023/189] do not fail whole API when deregistering machined --- .../src/state/provisioning/hooks/machined.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index ec5367b..c524bb1 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -59,15 +59,20 @@ impl ProvisioningHook for CHMachineProvisioningHook { }) } - fn after_stop(&self, vmid: &str) -> HookFuture<'_> { + fn before_stop(&self, vmid: &str) -> HookFuture<'_> { let vmid = vmid.to_string(); Box::pin(async move { tracing::info!(vmid, "Unregistering machine from systemd-machined"); let manager = get_manager_proxy().await?; - manager - .unregister_machine(vmid) + let res = manager + .unregister_machine(vmid.clone()) .await - .wrap_err("Failed to unregister machine from systemd-machined")?; + .wrap_err("Failed to unregister machine from systemd-machined"); + + // don't fail if hook fails, just log the error + if let Err(e) = res { + tracing::error!(vmid, error = ?e, "Failed to unregister machine from systemd-machined"); + } Ok(()) }) From 226cb4b0554d0802102825b1170e597193542881 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 09:28:20 +0700 Subject: [PATCH 024/189] add boot hooks --- odorobo-agent/src/state/api.rs | 2 +- odorobo-agent/src/state/instance.rs | 73 ++++++++++++++++--- .../src/state/provisioning/hooks/machined.rs | 5 +- .../src/state/provisioning/hooks/mod.rs | 55 +++++++++++--- odorobo-agent/src/state/provisioning/mod.rs | 27 +++++-- odorobo-agent/src/state/transform/console.rs | 20 ++++- odorobo-agent/src/state/transform/mod.rs | 10 +-- .../src/state/transform/path_verify.rs | 4 +- 8 files changed, 157 insertions(+), 39 deletions(-) diff --git a/odorobo-agent/src/state/api.rs b/odorobo-agent/src/state/api.rs index d55b059..142f1e2 100644 --- a/odorobo-agent/src/state/api.rs +++ b/odorobo-agent/src/state/api.rs @@ -11,7 +11,7 @@ use stable_eyre::{ eyre::{WrapErr, eyre}, }; use std::path::Path; - +#[allow(dead_code)] pub async fn call( socket_path: &Path, method: Method, diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 8bd2eef..1b125f4 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -1,7 +1,7 @@ use cloud_hypervisor_client::{ SocketBasedApiClient, apis::{DefaultApi, Error as ChClientError}, - models::{self, VmInfo, VmmPingResponse}, + models::{self, VmConfig, VmInfo, VmmPingResponse}, }; use hyper::Method; use hyper::{Request, Response, body::Bytes}; @@ -72,11 +72,22 @@ impl VMInstance { } pub async fn boot(&self) -> Result<()> { + // boot hooks + let provisioner = super::provisioning::default_provisioner(); + + let vm_config = self.info().await?.config; + + provisioner.before_boot(self.vm_id(), &vm_config).await?; + self.conn() .boot_vm() .await .map_err(ChApiError::from) - .wrap_err(eyre!("Failed to boot VM {}", self.vm_id())) + .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; + + provisioner.after_boot(self.vm_id(), &vm_config).await?; + + Ok(()) } pub async fn pause(&self) -> Result<()> { @@ -115,7 +126,7 @@ impl VMInstance { } #[tracing::instrument] - pub async fn prepare_migration(&self) -> Result<()> { + pub async fn receive_migration(&self) -> Result<()> { let conn = self.conn(); trace!("Preparing VM for migration"); @@ -269,10 +280,52 @@ impl VMInstance { } } + pub async fn debug_console_path(&self) -> Result { + let vminfo = self.info().await?; + if let Some(debug_console) = vminfo.config.debug_console { + match debug_console.mode { + models::debug_console_config::Mode::Pty => { + debug!( + vm_id = self.vm_id(), + "VM has PTY debug console configured, returning PTY path" + ); + let console_path = debug_console.file.ok_or_else(|| { + eyre!("Debug console config is missing file path for PTY console") + })?; + Ok(console_path.into()) + } + _ => Err(eyre!( + "Debug console is configured but is not a PTY console, unsupported console type" + )), + } + } else { + Err(eyre!( + "VM does not have a debug console configured nor supported" + )) + } + } + + pub async fn open_debug_console(&self) -> Result { + let console_path = self.debug_console_path().await?; + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&console_path) + .wrap_err_with(|| { + eyre!( + "Failed to open PTY debug console device for {} at {}", + self.vm_id(), + console_path.display() + ) + })?; + Ok(tokio::fs::File::from_std(file)) + } + /// Spawn a new CH process and create a VMInstance for it. /// /// Waits for the socket to become available (polls up to ~30 seconds). /// Calls a backend to handle the actual CH process spawning - typically a systemd unit + #[allow(dead_code)] // this thing is used in the REST API pub async fn spawn(id: &str) -> Result { info!( vm_id = id, @@ -281,7 +334,8 @@ impl VMInstance { ); let provisioner = super::provisioning::default_provisioner(); - provisioner.start_instance(id).await?; + // nothing for now, transformers will be used instead? + provisioner.start_instance(id, &VmConfig::default()).await?; let instance = Self::new(id, Self::runtime_dir_for(id).join(SOCKET_FILE_NAME)); @@ -318,13 +372,15 @@ impl VMInstance { } let provisioner = super::provisioning::default_provisioner(); + let vm_config = self.info().await?.config; + self.conn() .shutdown_vmm() .await .map_err(ChApiError::from) .wrap_err(eyre!("Failed to shutdown VMM for {}", self.vm_id()))?; - provisioner.stop_instance(self.vm_id()).await?; + provisioner.stop_instance(self.vm_id(), &vm_config).await?; if let Err(err) = self.purge_instance_data() { warn!( @@ -378,7 +434,8 @@ impl VMInstance { trace!(vm_id = self.vm_id(), "Creating VM with provided config"); let mut config = config; trace!(vm_id = self.vm_id(), "Applying config transforms"); - apply_builtin_transforms(&mut config).wrap_err("Failed to apply config transforms")?; + apply_builtin_transforms(self.vm_id(), &mut config) + .wrap_err("Failed to apply config transforms")?; trace!(vm_id = self.vm_id(), "Saving config to runtime dir"); self.save_config(&config)?; @@ -392,10 +449,8 @@ impl VMInstance { if boot { debug!(vm_id = self.vm_id(), "Booting VM"); - self.conn() - .boot_vm() + self.boot() .await - .map_err(ChApiError::from) .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; } info!(vm_id = self.vm_id(), "VM created and booted"); diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index c524bb1..4bdf744 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -3,6 +3,7 @@ use crate::state::VMInstance; use crate::state::provisioning::hooks::{HookFuture, ProvisioningHook}; use crate::util::zbus_system_connection; +use cloud_hypervisor_client::models::VmConfig; use stable_eyre::eyre::Context; use stable_eyre::{Result, eyre::eyre}; use zbus_systemd::machine1::ManagerProxy; @@ -37,7 +38,7 @@ pub const SERVICE_CLASS: &str = "cloud-hypervisor"; pub struct CHMachineProvisioningHook; impl ProvisioningHook for CHMachineProvisioningHook { - fn after_start(&self, vmid: &str, pid: i32) -> HookFuture<'_> { + fn after_start(&self, vmid: &str, _config: &VmConfig, pid: i32) -> HookFuture<'_> { let vmid = vmid.to_string(); Box::pin(async move { tracing::info!(vmid, pid, "Registering machine with systemd-machined"); @@ -59,7 +60,7 @@ impl ProvisioningHook for CHMachineProvisioningHook { }) } - fn before_stop(&self, vmid: &str) -> HookFuture<'_> { + fn before_stop(&self, vmid: &str, _config: &VmConfig) -> HookFuture<'_> { let vmid = vmid.to_string(); Box::pin(async move { tracing::info!(vmid, "Unregistering machine from systemd-machined"); diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index 44051f0..7a4fdd8 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -1,4 +1,13 @@ //! Hooks for provisioning state. +//! +//! These are hooks, code that runs at various points during the provisioning lifecycle, that can be used to perform additional actions related to provisioning +//! +//! For example, networking setup, registering with systemd-machined, etc. +//! +//! They are different from transforms which are designed to modify the configuration itself +//! to accomodate for the host environment, while hooks provide ways for the host itself +//! to react to provisioning events and perform necessary setup/teardown actions. +use cloud_hypervisor_client::models::VmConfig; use stable_eyre::Result; use std::{future::Future, pin::Pin}; @@ -14,18 +23,26 @@ mod machined; pub type HookFuture<'a> = Pin> + Send + 'a>>; pub trait ProvisioningHook: Send + Sync { - fn before_start(&self, _vmid: &str) -> HookFuture<'_> { + fn before_start(&self, _vmid: &str, config: &VmConfig) -> HookFuture<'_> { Box::pin(async { Ok(()) }) } - fn after_start(&self, _vmid: &str, _pid: i32) -> HookFuture<'_> { + fn after_start(&self, _vmid: &str, _config: &VmConfig, _pid: i32) -> HookFuture<'_> { Box::pin(async { Ok(()) }) } - fn before_stop(&self, _vmid: &str) -> HookFuture<'_> { + fn before_stop(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { Box::pin(async { Ok(()) }) } - fn after_stop(&self, _vmid: &str) -> HookFuture<'_> { + fn after_stop(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { Box::pin(async { Ok(()) }) } + + fn before_boot(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + fn after_boot(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { + Box::pin(async { Ok(()) }) + } + // fn before_destroy(&self, _vmid: &str) -> HookFuture<'_> { // Box::pin(async { Ok(()) }) // } @@ -44,30 +61,44 @@ impl HookManager { self } - pub async fn before_start(&self, vmid: &str) -> Result<()> { + pub async fn before_start(&self, vmid: &str, config: &VmConfig) -> Result<()> { + for hook in &self.hooks { + hook.before_start(vmid, config).await?; + } + Ok(()) + } + + pub async fn after_start(&self, vmid: &str, config: &VmConfig, pid: i32) -> Result<()> { + for hook in &self.hooks { + hook.after_start(vmid, config, pid).await?; + } + Ok(()) + } + + pub async fn before_stop(&self, vmid: &str, config: &VmConfig) -> Result<()> { for hook in &self.hooks { - hook.before_start(vmid).await?; + hook.before_stop(vmid, config).await?; } Ok(()) } - pub async fn after_start(&self, vmid: &str, pid: i32) -> Result<()> { + pub async fn after_stop(&self, vmid: &str, config: &VmConfig) -> Result<()> { for hook in &self.hooks { - hook.after_start(vmid, pid).await?; + hook.after_stop(vmid, config).await?; } Ok(()) } - pub async fn before_stop(&self, vmid: &str) -> Result<()> { + pub async fn before_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { for hook in &self.hooks { - hook.before_stop(vmid).await?; + hook.before_boot(vmid, config).await?; } Ok(()) } - pub async fn after_stop(&self, vmid: &str) -> Result<()> { + pub async fn after_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { for hook in &self.hooks { - hook.after_stop(vmid).await?; + hook.after_boot(vmid, config).await?; } Ok(()) } diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs index 51c8cbf..0448921 100644 --- a/odorobo-agent/src/state/provisioning/mod.rs +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -4,6 +4,7 @@ //! Cloud Hypervisor process for a given instance mod hooks; mod systemd; +use cloud_hypervisor_client::models::VmConfig; use stable_eyre::{Result, eyre::Context}; use tracing::info; @@ -21,10 +22,10 @@ impl VMProvisioner { } #[tracing::instrument(skip(self))] - pub async fn start_instance(&self, vmid: &str) -> Result { + pub async fn start_instance(&self, vmid: &str, config: &VmConfig) -> Result { info!(?vmid, "Starting instance"); self.hooks - .before_start(vmid) + .before_start(vmid, config) .await .wrap_err_with(|| format!("before_start hook failed for VM {vmid}"))?; @@ -35,7 +36,7 @@ impl VMProvisioner { .wrap_err_with(|| format!("Failed to start VM instance {vmid}"))?; self.hooks - .after_start(vmid, pid) + .after_start(vmid, config, pid) .await .wrap_err_with(|| format!("after_start hook failed for VM {vmid}"))?; @@ -43,10 +44,10 @@ impl VMProvisioner { } #[tracing::instrument(skip(self))] - pub async fn stop_instance(&self, vmid: &str) -> Result<()> { + pub async fn stop_instance(&self, vmid: &str, config: &VmConfig) -> Result<()> { info!(?vmid, "Stopping instance"); self.hooks - .before_stop(vmid) + .before_stop(vmid, config) .await .wrap_err_with(|| format!("before_stop hook failed for VM {vmid}"))?; @@ -56,10 +57,24 @@ impl VMProvisioner { .wrap_err_with(|| format!("Failed to stop VM instance {vmid}"))?; self.hooks - .after_stop(vmid) + .after_stop(vmid, config) .await .wrap_err_with(|| format!("after_stop hook failed for VM {vmid}")) } + + pub async fn before_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { + self.hooks + .before_boot(vmid, config) + .await + .wrap_err_with(|| format!("before_start hook failed for VM {vmid}")) + } + + pub async fn after_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { + self.hooks + .after_boot(vmid, config) + .await + .wrap_err_with(|| format!("after_start hook failed for VM {vmid}")) + } } pub trait VMProvisionerBackend: Send + Sync { diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs index 5cd5ee0..7c30eb4 100644 --- a/odorobo-agent/src/state/transform/console.rs +++ b/odorobo-agent/src/state/transform/console.rs @@ -1,7 +1,9 @@ -use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use cloud_hypervisor_client::models::{ConsoleConfig, DebugConsoleConfig, VmConfig}; use stable_eyre::Result; use tracing::trace; +use crate::state::VMInstance; + use super::ConfigTransform; #[derive(Debug, Clone)] @@ -9,12 +11,26 @@ pub struct ConsoleTransform; impl ConfigTransform for ConsoleTransform { #[tracing::instrument(skip(config))] - fn transform(&self, config: &mut VmConfig) -> Result<()> { + fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()> { + let runtime_path = VMInstance::runtime_dir_for(vmid); trace!("Applying ConsoleTransform"); config.console = Some(ConsoleConfig { mode: cloud_hypervisor_client::models::console_config::Mode::Pty, ..Default::default() }); + + config.debug_console = Some(DebugConsoleConfig { + mode: cloud_hypervisor_client::models::debug_console_config::Mode::Pty, + file: Some(format!("{}/debug_console.sock", runtime_path.display())), + ..Default::default() + }); + + config.vsock = Some(cloud_hypervisor_client::models::VsockConfig { + cid: 3, + id: Some("odorobo-vsock".into()), + socket: format!("{}/vsock.sock", runtime_path.display()), + ..Default::default() + }); Ok(()) } } diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 85cb4bb..65975ae 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -2,7 +2,7 @@ use cloud_hypervisor_client::models::VmConfig; use stable_eyre::Result; pub trait ConfigTransform: Send + Sync { - fn transform(&self, config: &mut VmConfig) -> Result<()>; + fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()>; } mod console; @@ -29,18 +29,18 @@ impl TransformChain { } impl ConfigTransform for TransformChain { - fn transform(&self, config: &mut VmConfig) -> Result<()> { + fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()> { for t in &self.0 { - t.transform(config)?; + t.transform(vmid, config)?; } Ok(()) } } -pub fn apply_builtin_transforms(config: &mut VmConfig) -> Result<()> { +pub fn apply_builtin_transforms(vmid: &str, config: &mut VmConfig) -> Result<()> { TransformChain::new() .add(ConsoleTransform) .add(PathVerify) .then() - .transform(config) + .transform(vmid, config) } diff --git a/odorobo-agent/src/state/transform/path_verify.rs b/odorobo-agent/src/state/transform/path_verify.rs index 7a6564e..34aa96b 100644 --- a/odorobo-agent/src/state/transform/path_verify.rs +++ b/odorobo-agent/src/state/transform/path_verify.rs @@ -3,7 +3,7 @@ //! Verifies paths for validity, returning errors when an invalid path is found use std::path::Path; -use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; +use cloud_hypervisor_client::models::VmConfig; use stable_eyre::{Result, eyre::eyre}; use tracing::trace; @@ -14,7 +14,7 @@ pub struct PathVerify; impl ConfigTransform for PathVerify { #[tracing::instrument(skip(config))] - fn transform(&self, config: &mut VmConfig) -> Result<()> { + fn transform(&self, _vmid: &str, config: &mut VmConfig) -> Result<()> { trace!("Verifying paths"); let config = config.clone(); // payload path verification From bdf730f17ec1183b79ff9827533d76de6e9e3c0b Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 10:29:25 +0700 Subject: [PATCH 025/189] document debug console access --- HACKING.md | 39 +++++++++++++++++++++++++++++++++++++++ scripts/vsock.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 HACKING.md create mode 100644 scripts/vsock.py diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..4d0a253 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,39 @@ +## agetty-over-vsock + +To create a fresh agetty session over vsock, you can do this: + +on the guest: +```bash +# optional: set SELinux to permissive so it doesn't interfere with the pty creation +# consider writing some kind of policy to fix this properly, but for quick testing this is fine +sudo setenforce 0 +# run socat to listen on vsock port 1234 and spawn agetty for each connection, as root +sudo socat VSOCK-LISTEN:1234,reuseaddr,fork EXEC:'/sbin/agetty --noclear -J - xterm-256color',pty,setsid,ctty,raw,echo=0 +``` + +on the host: +```bash +# use the vsock script, script +python scripts/vsock.py my-vm +``` + +Fyra Stack production images should just have a management agent to talk over vsock and do other stuff, +but this is a quick way to get a shell over vsock for testing and debugging. + +## virtio-console socket + +To connect to the virtio-console socket directly, you will need to find the PTY allocated for it + +```bash +odoroboctl info my-vm +``` + +Look at the `config.console` section of the output, and find the `path` field. This is the path to the PTY on the host that is connected to the VM's virtio-console. You can connect to this socket with `screen` or `minicom` or any other terminal program: + +```bash +screen /dev/pts/N +``` + +Where `N` is the number from the `path` field in the `config.console` section of the `odoroboctl info` output. + +odoroboctl should implement the WebSocket tty proxy later, but for now this is how you get a shell on the console directly \ No newline at end of file diff --git a/scripts/vsock.py b/scripts/vsock.py new file mode 100644 index 0000000..c864d55 --- /dev/null +++ b/scripts/vsock.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import os +import socket +import sys +import termios +import tty + +vmname = sys.argv[1] +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect(f"/run/odorobo/vms/{vmname}/vsock.sock") +sock.sendall(b"CONNECT 1234\n") + +# read the OK response +resp = b"" +while b"\n" not in resp: + resp += sock.recv(1) + +# put terminal in raw mode +fd = sys.stdin.fileno() +old = termios.tcgetattr(fd) +tty.setraw(fd) + +try: + import select + + while True: + r, _, _ = select.select([sock, sys.stdin], [], []) + if sock in r: + data = sock.recv(1024) + if not data: + break + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + if sys.stdin in r: + data = os.read(fd, 1024) + if not data: + break + sock.sendall(data) +finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) From b3fa27cefe1e21612f04db4dd21c329ac8f3d7aa Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 11:32:12 +0700 Subject: [PATCH 026/189] update documentation --- README.md | 19 +++++++++++++++---- justfile | 3 ++- systemd/odorobo-ch@.service | 4 ++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a22ccaa..c6e7c77 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,18 @@ Odorobo Agent is meant to be run as a system agent on each bare-metal node (or a Build the Agent binary with `cargo build --release` and run it on the host machine. The Agent will listen for commands from the Gateway to create, manage, and delete VMs. +Install the systemd integration first: +```bash +# Create the `odorobo` user (the unit is hardcoded to run as this for now) +sudo useradd -r -s /usr/sbin/nologin odorobo +# Install unit hook scripts +sudo just install_script +# finally, install the unit +sudo just install_unit ``` + +Then build and run the agent: +```bash # Build the Agent cargo build --release # Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus @@ -40,19 +51,19 @@ For debugging and/or small-scale single-node usage, the CLI is available to inte Install the CLI helper -``` +```bash cargo install --path odoroboctl ``` You can then use `odoroboctl` to directly interact with the Agent, for example to spawn a VM instance -``` +```bash odoroboctl spawn my-vm ``` Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/cloud-hypervisor/blob/main/docs/api.md#create-a-virtual-machine) to the instance, for example with a simple configuration that boots from a disk image -``` +```bash # the `--boot` flag additionally also tells Cloud Hypervisor to boot the VM after applying the configuration, otherwise it will stay # in the "Created" state, requiring a separate `odoroboctl boot` call to start it. odoroboctl create my-vm --boot ./my-vm.json @@ -60,7 +71,7 @@ odoroboctl create my-vm --boot ./my-vm.json Now the VM should be running. You can connect to the VM's virtio-console with: -``` +```bash screen /run/odorobo/my-vm/console.sock ``` diff --git a/justfile b/justfile index fbb61dd..b554cea 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,7 @@ PREFIX := "/usr/local" BINDIR := PREFIX / "bin" LIBEXECDIR := PREFIX / "libexec" DATADIR := PREFIX / "share" +USRLIBDIR := PREFIX / "lib" build: build_agent @@ -22,7 +23,7 @@ install_script: install -Dm755 systemd/scripts/odorobo-cleanup {{ LIBEXECDIR }}/odorobo-cleanup install_unit: - install -Dm644 systemd/odorobo-ch@.service {{ DATADIR }}/systemd/user/odorobo-ch@.service + install -Dm644 systemd/odorobo-ch@.service {{ USRLIBDIR }}/systemd/system/odorobo-ch@.service install_agent: build_agent install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index e7df365..02ed7c8 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -1,9 +1,9 @@ [Unit] Description=Odorobo Cloud Hypervisor VM (%i) Documentation=https://www.cloudhypervisor.org/docs/ -After=network-online.target odorobo-agent.service +After=network-online.target Wants=network-online.target -Requires=odorobo-agent.service +#Requires=odorobo-agent.service # If this unit fails, don't take down other VMs OnFailure=odorobo-ch-failed@%i.service From 00f1748c6417ee6c3957a2a75deef1e7179b6e18 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 11:41:19 +0700 Subject: [PATCH 027/189] fix install scripts again --- justfile | 8 ++++++-- systemd/odorobo-ch@.service | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/justfile b/justfile index b554cea..1fd6859 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,10 @@ BINDIR := PREFIX / "bin" LIBEXECDIR := PREFIX / "libexec" DATADIR := PREFIX / "share" USRLIBDIR := PREFIX / "lib" +# systemd unit load paths +# defaults to /etc, but can be overridden by setting SYSTEMD_UNITDIR to a different path +# usually /usr/lib/systemd/system +SYSTEMD_UNITDIR := "/etc" build: build_agent @@ -11,7 +15,7 @@ build_agent: build_agent_debug: cargo build -p odorobo-agent - + debug: build_agent_debug sudo target/debug/odorobo-agent @@ -23,7 +27,7 @@ install_script: install -Dm755 systemd/scripts/odorobo-cleanup {{ LIBEXECDIR }}/odorobo-cleanup install_unit: - install -Dm644 systemd/odorobo-ch@.service {{ USRLIBDIR }}/systemd/system/odorobo-ch@.service + install -Dm644 systemd/odorobo-ch@.service {{ SYSTEMD_UNITDIR }}/systemd/system/odorobo-ch@.service install_agent: build_agent install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 02ed7c8..6789c59 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -26,7 +26,7 @@ ExecStart=/usr/bin/cloud-hypervisor \ --api-socket /run/odorobo/vms/%i/ch.sock ExecStartPost=/usr/local/libexec/odorobo-postflight %i -ExecStop=/usr/local/libexec/odorobo-stop %i +ExecStop=/usr/local/libexec/odorobo-cleanup %i Restart=on-failure RestartSec=3s From b41b7877c2aedc7492de4d16f9604df1f28355c9 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 11:59:37 +0700 Subject: [PATCH 028/189] set runtime dir mode --- systemd/odorobo-ch@.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 6789c59..9c451de 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -14,7 +14,7 @@ User=odorobo Group=odorobo RuntimeDirectory=odorobo/vms/%i -RuntimeDirectoryMode=0750 +RuntimeDirectoryMode=0770 LogsDirectory=odorobo LogsDirectoryMode=0750 ConfigurationDirectory=odorobo/vms/%i From adcce7d86ae4f3ab7972a0c3df7baaf25e6ebc41 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 30 Mar 2026 12:08:13 +0700 Subject: [PATCH 029/189] add sandboxing notes --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c6e7c77..2e433cb 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,9 @@ See [docs/console.md](docs/console.md) for PTY-over-WebSocket usage and integrat For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. + +## Security notes + +Currently, the `odorobo-ch@.service` unit is configured to be sandboxed and confined to a list of read-writable paths that are necessary for operation, and by default only has access to `/var/lib/odorobo` and `/dev` for runtime data. + +To allow Cloud Hypervisor to access the disk, you will have to either move your disk images into `/var/lib/odorobo` or add additional read access to the paths where your disk images are stored. From c93e237cf36bf0b819d6f24dfd959603a37beac0 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 3 Apr 2026 14:09:24 +0700 Subject: [PATCH 030/189] implement live migration API --- .gitignore | 8 +++ Cargo.lock | 20 +++++++ README.md | 9 +++ odorobo-agent/Cargo.toml | 1 + odorobo-agent/src/api/error.rs | 8 +++ odorobo-agent/src/api/mod.rs | 29 ++++++++- odorobo-agent/src/api/vm.rs | 51 ++++++++++++++-- odorobo-agent/src/main.rs | 12 +++- odorobo-agent/src/state/instance.rs | 60 ++++++++++++++----- .../src/state/provisioning/hooks/machined.rs | 11 +++- odoroboctl/src/cli.rs | 33 +++++++++- 11 files changed, 212 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 66900dc..27e517e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ dev # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# AI Slop +# Anyone who uses LLMs for slop gets to keep their own slop context. +# sharing context is technically good, but bad for optics for the anti-AI crowd, so +# everyone keeps their own LLM configs +AGENTS.md +CLAUDE.md +.claude/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0f5db6e..c96db9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1252,6 +1252,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "indenter" version = "0.3.4" @@ -1512,6 +1522,7 @@ dependencies = [ "hyper", "hyper-util", "hyperlocal", + "if-addrs", "libc", "random-port", "serde", @@ -2978,6 +2989,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/README.md b/README.md index 2e433cb..437b05e 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,15 @@ For more advanced usage, Odorobo Agent also exposes a passthrough route for the See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. +## Live Migration + +To start a live migration, you will first need to spawn a VM on the destination node with `odoroboctl spawn`, then call the `migrate` command on the source node with the destination node's address: + +```bash +odoroboctl spawn my-vm-dest +``` + + ## Security notes Currently, the `odorobo-ch@.service` unit is configured to be sandboxed and confined to a list of read-writable paths that are necessary for operation, and by default only has access to `/var/lib/odorobo` and `/dev` for runtime data. diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index e7c452f..0d03da4 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -24,6 +24,7 @@ futures-util = "0.3.31" libc = "0.2" tower-http = { workspace = true, features = ["trace"] } random-port = "0.1.1" +if-addrs = "0.13" zbus = "5.14.0" zbus_systemd = { version = "0.26000.0", features = ["machine1", "systemd1", "zbus-async-tokio"] } diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index c744c3a..4c85abb 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -44,6 +44,10 @@ pub enum ApiError { #[error("Failed to delete VM configuration: configuration: {msg}")] #[http(code = 500, message = msg, errors = errors)] CreateConfigFailed { msg: String, errors: Vec }, + + #[error("Failed to migrate VM: {msg}")] + #[http(code = 500, message = msg, errors = errors)] + MigrationFailed { msg: String, errors: Vec }, } impl ApiError { @@ -88,4 +92,8 @@ impl ApiError { errors, }) } + + pub fn migration(error: Report) -> Self { + Self::from_report(error, |msg, errors| Self::MigrationFailed { msg, errors }) + } } diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index 4be41d5..382adb0 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -2,9 +2,16 @@ mod ch; mod console; mod error; +use axum::{Json, extract::State}; +use serde::Serialize; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; mod vm; -pub fn router() -> axum::Router<()> { + +pub fn router(port: u16) -> axum::Router<()> { + let info_route = axum::Router::new() + .route("/info", axum::routing::get(info)) + .with_state(port); + axum::Router::new() .layer( TraceLayer::new_for_http() @@ -13,6 +20,7 @@ pub fn router() -> axum::Router<()> { ) .route("/", axum::routing::get(root)) .route("/health", axum::routing::get(health)) + .merge(info_route) .nest("/vms", vm::router()) } @@ -23,3 +31,22 @@ async fn root() -> &'static str { async fn health() -> &'static str { "" } + +#[derive(Serialize)] +struct AgentInfo { + version: &'static str, + listening_addresses: Vec, +} + +async fn info(State(port): State) -> Json { + let listening_addresses = if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter(|i| !i.is_loopback()) + .map(|i| format!("{}:{}", i.ip(), port)) + .collect(); + Json(AgentInfo { + version: env!("CARGO_PKG_VERSION"), + listening_addresses, + }) +} diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 3e45194..48b30b4 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -21,6 +21,11 @@ pub fn router() -> axum::Router<()> { .route("/{vmid}/boot", axum::routing::put(boot_vm)) .route("/{vmid}/pause", axum::routing::put(pause_vm)) .route("/{vmid}/resume", axum::routing::put(resume_vm)) + .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) + .route( + "/{vmid}/migrate/receive", + axum::routing::put(migrate_receive_vm), + ) .route("/{vmid}", axum::routing::get(vm_info)) .route("/{vmid}/ping", axum::routing::get(ping_vm)) .route("/{vmid}", axum::routing::delete(destroy_vm)) @@ -82,6 +87,44 @@ pub struct VmSpawnResponse { pub created_config: bool, } +#[derive(Debug, Deserialize, Serialize)] +pub struct VmMigrateSendResponse { + pub info: Option, +} +#[derive(Debug, Deserialize, Serialize)] +pub struct VmMigrateReceiveResponse { + pub listening_address: String, +} + +#[derive(Debug, Deserialize)] +pub struct VmMigrateSendRequest { + pub destination: String, + #[serde(default)] + pub local: bool, +} + +/// Sends a live migration to the given destination URL +async fn migrate_send_vm( + vmid: Path, + Json(body): Json, +) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + vm.send_migration(&body.destination, body.local) + .await + .map_err(ApiError::migration)?; + let info = vm.info().await.map_err(ApiError::vm_info)?; + Ok(Json(VmMigrateSendResponse { info: Some(info) })) +} + +/// Prepares a VM to receive a live migration, returning the address the sender should connect to +async fn migrate_receive_vm( + vmid: Path, +) -> Result, ApiError> { + let vm = get_vm(&vmid.0)?; + let listening_address = vm.receive_migration().await.map_err(ApiError::migration)?; + Ok(Json(VmMigrateReceiveResponse { listening_address })) +} + /// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately async fn spawn_vm( vmid: Path, @@ -162,9 +205,7 @@ async fn create_vm_config( async fn delete_vm_config(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.delete_config() - .await - .map_err(ApiError::delete_config)?; + vm.delete_config().await.map_err(ApiError::delete_config)?; Ok(Json(())) } @@ -185,9 +226,7 @@ async fn shutdown_vm(vmid: Path) -> Result, ApiError> { /// allowing them to be re-provisioned again on any other node (if running in a cluster) async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { let vm = get_vm(&vmid.0)?; - vm.acpi_power_button() - .await - .map_err(ApiError::vm_info)?; + vm.acpi_power_button().await.map_err(ApiError::vm_info)?; Ok(Json(())) } diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 7adc735..aa7052c 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,6 +1,6 @@ mod api; -mod util; mod state; +mod util; use stable_eyre::Result; #[tokio::main] async fn main() -> Result<()> { @@ -16,8 +16,14 @@ async fn main() -> Result<()> { // minimal axum server let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; - tracing::info!("Listening on http://{}", listener.local_addr()?); - axum::serve(listener, api::router()).await?; + let port = listener.local_addr()?.port(); + let addrs: Vec = if_addrs::get_if_addrs()? + .into_iter() + .filter(|i| !i.is_loopback()) + .map(|i| format!("http://{}:{}", i.ip(), port)) + .collect(); + tracing::info!(port, ?addrs, "Listening"); + axum::serve(listener, api::router(port)).await?; Ok(()) } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 1b125f4..658a21c 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -16,7 +16,7 @@ use std::{ path::{Path, PathBuf}, }; use thiserror::Error; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; use super::api::{call, call_request}; use super::transform::apply_builtin_transforms; @@ -106,13 +106,19 @@ impl VMInstance { .wrap_err(eyre!("Failed to resume VM {}", self.vm_id())) } + /// Initiates a migration from this VM to the specified destination URI. + /// + /// Options: + /// - `dest`: the destination URI to migrate to, in the format expected by CH (e.g. "tcp::12345") + /// - `local`: if true, indicates that the migration is local (e.g. within the same host, for renaming a VM). This is passed to CH and may affect how the migration is performed. #[tracing::instrument] - pub async fn send_migration(&self, dest: &str) -> Result<()> { + pub async fn send_migration(&self, dest: &str, local: bool) -> Result<()> { let conn = self.conn(); trace!(destination = dest, "Sending migration command to VM"); let send_migration_data = models::SendMigrationData { destination_url: dest.to_string(), + local: Some(local), ..Default::default() }; @@ -125,34 +131,50 @@ impl VMInstance { )) } + /// Prepares the VM to receive a migration by starting a migration receiver in the background. + /// Returns the URI that the sender should connect to for migration. + /// + /// Note: This does not currently track migration state, + /// so it's currently up to the caller to make sure that the receiver is ready before the sender tries to connect. + /// Future improvement: add some kind of global tracker for active migrations and their states. #[tracing::instrument] - pub async fn receive_migration(&self) -> Result<()> { + pub async fn receive_migration(&self) -> Result { let conn = self.conn(); trace!("Preparing VM for migration"); - let rand_port = random_port::PortPicker::new().pick()?; + let rand_port = random_port::PortPicker::new() + .port_range(49152u16..=65535u16) + .pick()?; trace!(port = rand_port, "Selected random port for migration"); - let reciever_uri = format!("tcp:0.0.0.0:{}", rand_port); + let receiver_uri = format!("tcp:0.0.0.0:{}", rand_port); let receive_migration_data = models::ReceiveMigrationData { - receiver_url: reciever_uri.clone(), + receiver_url: receiver_uri.clone(), ..Default::default() }; - conn.vm_receive_migration_put(receive_migration_data) - .await - .map_err(ChApiError::from) - .wrap_err(eyre!("Failed to prepare VM for migration {}", self.vm_id()))?; - + let vm_id = self.vm_id().to_string(); info!( - vm_id = self.vm_id(), - uri = ?reciever_uri, - "VM prepared for migration, receiver listening" + vm_id, + port = rand_port, + "Preparing VM for migration, spawning receiver in background" ); - Ok(()) + tokio::spawn(async move { + match conn + .vm_receive_migration_put(receive_migration_data) + .await + .map_err(ChApiError::from) + .wrap_err(eyre!("Failed to prepare VM for migration {}", vm_id)) + { + Ok(_) => info!(vm_id, "Migration receiver completed successfully"), + Err(e) => error!(vm_id, error = ?e, "Migration receiver failed"), + } + }); + + Ok(receiver_uri) } pub fn runtime_root() -> PathBuf { @@ -372,7 +394,13 @@ impl VMInstance { } let provisioner = super::provisioning::default_provisioner(); - let vm_config = self.info().await?.config; + let vm_config = self + .info() + .await + .ok() + .map(|i| i.config) + .or_else(|| self.load_config().ok()) + .unwrap_or_default(); self.conn() .shutdown_vmm() diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index 4bdf744..be8a420 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -44,9 +44,9 @@ impl ProvisioningHook for CHMachineProvisioningHook { tracing::info!(vmid, pid, "Registering machine with systemd-machined"); let runtime_dir = VMInstance::runtime_dir_for(&vmid); let manager = get_manager_proxy().await?; - manager + let res = manager .register_machine( - vmid, + vmid.clone(), Vec::new(), SERVICE_CLASS.to_string(), "vm".to_string(), @@ -54,7 +54,12 @@ impl ProvisioningHook for CHMachineProvisioningHook { runtime_dir.display().to_string(), ) .await - .wrap_err("Failed to register machine with systemd-machined")?; + .wrap_err("Failed to register machine with systemd-machined"); + + if let Err(e) = res { + tracing::error!(vmid, error = ?e, "Failed to register machine with systemd-machined"); + tracing::warn!(vmid, "Continuing without systemd-machined registration"); + } Ok(()) }) diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 55f0f92..a87d098 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -92,8 +92,26 @@ pub enum Command { #[arg(last = true)] args: Vec, }, -} + /// Migrate a VM to another host + MigrateSend { + /// VM ID of the source VM to migrate + vmid: String, + + /// Address of the destination host to migrate to + destination: String, + + /// Indicate that this is a local migration (e.g. within the same host) + #[arg(long)] + local: bool, + }, + + /// Prepare a VM to receive a migration from another host + MigrateReceive { + /// VM ID of the destination VM to prepare for migration + vmid: String, + }, +} // the fields are used using debug printing, so we allow dead code warnings #[allow(dead_code)] @@ -243,6 +261,19 @@ pub async fn run_command(cli: Cli) -> Result<()> { print_message_response(response, "VM destroyed successfully").await?; } + Command::MigrateSend { vmid, destination, local } => { + let url = format!("{base_url}/{vmid}/migrate/send"); + let body = serde_json::json!({ "destination": destination, "local": local }); + let response = client.put(&url).json(&body).send().await?; + print_text_response(response).await?; + } + + Command::MigrateReceive { vmid } => { + let url = format!("{base_url}/{vmid}/migrate/receive"); + let response = client.put(&url).send().await?; + print_text_response(response).await?; + } + Command::ChRemote { vmid, args } => { let runtime_dir = cli.agent_runtime_dir; let socket_id = format!("{runtime_dir}/vms/{vmid}/ch.sock"); From 2ab17e31d6f9f8ca5fc6425dd48573c0b6e97eff Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 3 Apr 2026 16:54:49 +0700 Subject: [PATCH 031/189] new console socket, run as root, disable sandboxing for now, attempt to clean up live migration Currently live migration is a bit janky in testing due to weird failures with networking, and console shenanigans. The console is now a serial console, however they might fail migration, so disable if needed --- Cargo.lock | 52 +++++++++++ Cargo.toml | 2 +- HACKING.md | 26 ++++-- README.md | 15 +-- docs/console.md | 44 ++++++--- justfile | 2 + odorobo-agent/src/api/vm.rs | 6 +- odorobo-agent/src/main.rs | 18 +++- odorobo-agent/src/state/instance.rs | 91 ++++++------------- .../src/state/provisioning/hooks/machined.rs | 48 ++++++---- .../src/state/provisioning/systemd/proxy.rs | 63 +++++++++++-- odorobo-agent/src/state/transform/console.rs | 36 +++++--- systemd/odorobo-ch@.service | 12 +-- systemd/scripts/odorobo-preflight | 2 +- 14 files changed, 276 insertions(+), 141 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c96db9f..9ad184c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1423,6 +1432,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1853,6 +1871,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.13.2" @@ -2579,18 +2614,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f423b3d..6d8e289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["odorobo-agent", "odoroboctl"] [workspace.dependencies] stable-eyre = "0.2.2" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = {version = "0.3", features = ["env-filter", "fmt", "json"] } cloud-hypervisor-client = "0.3" serde_json = "1.0.149" serde = "1.0" diff --git a/HACKING.md b/HACKING.md index 4d0a253..cede486 100644 --- a/HACKING.md +++ b/HACKING.md @@ -20,20 +20,30 @@ python scripts/vsock.py my-vm Fyra Stack production images should just have a management agent to talk over vsock and do other stuff, but this is a quick way to get a shell over vsock for testing and debugging. -## virtio-console socket +## serial console socket -To connect to the virtio-console socket directly, you will need to find the PTY allocated for it +Cloud Hypervisor is configured to expose the serial console as a Unix domain socket at a stable, predictable path: -```bash -odoroboctl info my-vm +``` +/run/odorobo/vms//console.sock ``` -Look at the `config.console` section of the output, and find the `path` field. This is the path to the PTY on the host that is connected to the VM's virtio-console. You can connect to this socket with `screen` or `minicom` or any other terminal program: +Connect directly on the host with socat: ```bash -screen /dev/pts/N +# raw output only (useful for scripting or piping) +socat - UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock + +# interactive session with local terminal in raw mode +socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock ``` -Where `N` is the number from the `path` field in the `config.console` section of the `odoroboctl info` output. +Press `Ctrl-]` then `q` to exit socat in interactive mode, or just close the terminal. + +Alternatively, use the agent's WebSocket console proxy from any machine that can reach the agent: + +```bash +websocat --binary ws://127.0.0.1:8890/vms/my-vm/console +``` -odoroboctl should implement the WebSocket tty proxy later, but for now this is how you get a shell on the console directly \ No newline at end of file +The socket path is stable across live migrations — after a migration completes, reconnect to the same path on the destination node. \ No newline at end of file diff --git a/README.md b/README.md index 437b05e..b6552ba 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,6 @@ Build the Agent binary with `cargo build --release` and run it on the host machi Install the systemd integration first: ```bash -# Create the `odorobo` user (the unit is hardcoded to run as this for now) -sudo useradd -r -s /usr/sbin/nologin odorobo # Install unit hook scripts sudo just install_script # finally, install the unit @@ -69,14 +67,19 @@ Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/clo odoroboctl create my-vm --boot ./my-vm.json ``` -Now the VM should be running. You can connect to the VM's virtio-console with: +Now the VM should be running. You can connect to the VM's serial console directly on the host: ```bash -screen /run/odorobo/my-vm/console.sock +socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock ``` -There is also a WebSocket proxy that can be used to connect to the console over WebSockets, for example with `websocat`. -See [docs/console.md](docs/console.md) for PTY-over-WebSocket usage and integration details. +Or connect remotely via the agent's WebSocket proxy, for example with `websocat`: + +```bash +websocat --binary ws://127.0.0.1:8890/vms/my-vm/console +``` + +See [docs/console.md](docs/console.md) for serial console WebSocket usage and integration details. For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API diff --git a/docs/console.md b/docs/console.md index ef22fc3..c70fa46 100644 --- a/docs/console.md +++ b/docs/console.md @@ -1,28 +1,28 @@ # Console API -## PTY over WebSocket +## Serial console over WebSocket The agent exposes a VM console bridge at: `GET /vms/{vmid}/console` -This endpoint upgrades to a WebSocket and proxies the VM's PTY console in both directions. +This endpoint upgrades to a WebSocket and proxies the VM's serial console in both directions. The console is backed by a Unix domain socket at `/run/odorobo/vms/{vmid}/console.sock`, which Cloud Hypervisor manages directly via its serial console socket mode. ## Connection behavior - Connect with a standard WebSocket client to `ws://:8890/vms//console` -- The upgrade succeeds only if the VM exists and the agent can open its PTY console +- The upgrade succeeds only if the VM exists and the agent can connect to its console socket - If the VM does not exist, the HTTP request returns `404` -- If the PTY cannot be opened, the HTTP request returns `500` +- If the console socket cannot be opened, the HTTP request returns `500` - After the upgrade completes, terminal bytes flow over the socket until either side disconnects ## Frame semantics - Client -> VM: - - WebSocket `Binary` frames are written to the PTY as-is + - WebSocket `Binary` frames are written to the console socket as-is - WebSocket `Text` frames are reserved for JSON control messages - VM -> Client: - - PTY output is sent back as WebSocket `Binary` frames + - Console output is sent back as WebSocket `Binary` frames - Server control or protocol errors are sent as WebSocket `Text` frames containing JSON - Control frames: - WebSocket `Ping` receives a `Pong` @@ -32,19 +32,19 @@ In practice, treat this as a raw byte stream carried over WebSocket frames. ## Resize message -Clients resize the PTY by sending a JSON text frame like: +The resize control message is accepted but has no effect with socket-backed serial consoles: ```json {"type":"resize","cols":120,"rows":40} ``` -Optional pixel dimensions are also supported: +Optional pixel dimensions are also accepted: ```json {"type":"resize","cols":120,"rows":40,"x_pixels":960,"y_pixels":720} ``` -The agent applies the new TTY window size on the host PTY with `TIOCSWINSZ`, so the guest can observe the updated size through normal terminal mechanisms. +Unlike PTY-backed consoles, a Unix socket has no associated terminal window size. `TIOCSWINSZ` is not applicable here, so the guest cannot observe a resize event through this mechanism. Terminal size negotiation must be handled at the application layer if needed. ## Reset-session message @@ -54,7 +54,7 @@ Clients can ask the agent to try to return the console to a fresh login-like sta {"type":"reset_session"} ``` -This sends a conservative control sequence to the PTY: +This sends a conservative control sequence to the console: - `Ctrl-C` to interrupt a foreground shell command if possible - `Enter` to try to land on a clean prompt @@ -64,15 +64,17 @@ This is best-effort only. The guest decides what those bytes mean. ## Important implementation notes -- Do not assume one terminal message maps to one WebSocket frame; PTY output is chunked arbitrarily +- Do not assume one terminal message maps to one WebSocket frame; console output is chunked arbitrarily - Send terminal input as binary bytes, not text frames - Text frames should be treated as a small control channel for messages like resize requests, reset requests, and error events - If the server receives an invalid control message, it responds with a JSON text frame like `{"type":"error","message":"..."}` and keeps the session open - This API is transport-only; terminal emulation, ANSI parsing, scrollback, and rendering are client responsibilities -- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on `hvc0` -- On virtio-console or serial-backed Linux sessions, `Ctrl-D` only causes a logout when the foreground process interprets it as EOF +- The `resize` message is accepted but has no effect; the console socket has no associated terminal size +- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on the serial device +- `Ctrl-D` only causes a logout when the foreground process interprets it as EOF - If the guest is running `vim`, `less`, a full-screen app, or a raw-mode program, `reset_session` may not produce a fresh login prompt - For deterministic fresh sessions, configure the guest to respawn `getty` on the console device after shell exit +- The console socket at `/run/odorobo/vms/{vmid}/console.sock` is managed by Cloud Hypervisor and survives live migration; the WebSocket connection will break during migration but the socket path remains stable on the destination node ## Browser example @@ -125,10 +127,22 @@ function resetSession() { } ``` -## CLI example with websocat +## CLI examples + +Connect via the WebSocket bridge with websocat: ```bash websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -This is the simplest way to verify the bridge works before integrating it into a browser terminal such as xterm.js. +Connect directly to the console socket on the host (useful for debugging): + +```bash +# raw output only +socat - UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock + +# with local terminal in raw mode (proper interactive session) +socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock +``` + +The websocat approach works from any machine that can reach the agent. The socat approach requires local host access but bypasses the agent entirely and is useful for low-level debugging or when the agent is not running. diff --git a/justfile b/justfile index 1fd6859..39ab70a 100644 --- a/justfile +++ b/justfile @@ -3,9 +3,11 @@ BINDIR := PREFIX / "bin" LIBEXECDIR := PREFIX / "libexec" DATADIR := PREFIX / "share" USRLIBDIR := PREFIX / "lib" + # systemd unit load paths # defaults to /etc, but can be overridden by setting SYSTEMD_UNITDIR to a different path # usually /usr/lib/systemd/system + SYSTEMD_UNITDIR := "/etc" build: build_agent diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 48b30b4..a62413c 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -103,7 +103,8 @@ pub struct VmMigrateSendRequest { pub local: bool, } -/// Sends a live migration to the given destination URL +/// Sends a live migration to the given destination URL. +/// Note: the source VMM exits after migration completes, so no VM info is returned. async fn migrate_send_vm( vmid: Path, Json(body): Json, @@ -112,8 +113,7 @@ async fn migrate_send_vm( vm.send_migration(&body.destination, body.local) .await .map_err(ApiError::migration)?; - let info = vm.info().await.map_err(ApiError::vm_info)?; - Ok(Json(VmMigrateSendResponse { info: Some(info) })) + Ok(Json(VmMigrateSendResponse { info: None })) } /// Prepares a VM to receive a live migration, returning the address the sender should connect to diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index aa7052c..511b25b 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -2,11 +2,27 @@ mod api; mod state; mod util; use stable_eyre::Result; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; + +fn env_filter() -> EnvFilter { + let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); + + let base = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse_lossy(&env); + + #[cfg(debug_assertions)] + let base = base.add_directive("odorobo_agent=trace".parse().unwrap()); + + base +} + #[tokio::main] async fn main() -> Result<()> { stable_eyre::install()?; tracing_subscriber::fmt() - .with_max_level(tracing::Level::TRACE) + .with_env_filter(env_filter()) .with_file(true) .with_line_number(true) .init(); diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 658a21c..063b0e9 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -12,7 +12,6 @@ use stable_eyre::{ }; use std::{ env, fs, - fs::OpenOptions, path::{Path, PathBuf}, }; use thiserror::Error; @@ -23,8 +22,10 @@ use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; +const CONSOLE_SOCKET_FILE_NAME: &str = "console.sock"; +const DEBUG_CONSOLE_SOCKET_FILE_NAME: &str = "debug_console.socket"; pub const VMS_DIR_NAME: &str = "vms"; -pub type ConsoleStream = tokio::fs::File; +pub type ConsoleStream = tokio::net::UnixStream; const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; @@ -107,7 +108,7 @@ impl VMInstance { } /// Initiates a migration from this VM to the specified destination URI. - /// + /// /// Options: /// - `dest`: the destination URI to migrate to, in the format expected by CH (e.g. "tcp::12345") /// - `local`: if true, indicates that the migration is local (e.g. within the same host, for renaming a VM). This is passed to CH and may affect how the migration is performed. @@ -225,19 +226,16 @@ impl VMInstance { /// Opens the PTY console device for this VM and returns a connected stream. pub async fn open_console(&self) -> Result { - let console_path = self.console_path().await?; - let file = OpenOptions::new() - .read(true) - .write(true) - .open(&console_path) + let socket_path = self.console_path(); + tokio::net::UnixStream::connect(&socket_path) + .await .wrap_err_with(|| { eyre!( - "Failed to open PTY console device for {} at {}", + "Failed to connect to console socket for {} at {}", self.vm_id(), - console_path.display() + socket_path.display() ) - })?; - Ok(tokio::fs::File::from_std(file)) + }) } pub fn ch_socket_path(&self) -> &Path { @@ -279,68 +277,25 @@ impl VMInstance { .wrap_err(eyre!("Failed to ping VM {}", self.vm_id())) } - pub async fn console_path(&self) -> Result { - let vminfo = self.info().await?; - if let Some(console) = vminfo.config.console { - match console.mode { - models::console_config::Mode::Pty => { - debug!( - vm_id = self.vm_id(), - "VM has PTY console configured, returning PTY path" - ); - let console_path = console.file.ok_or_else(|| { - eyre!("Console config is missing file path for PTY console") - })?; - Ok(console_path.into()) - } - _ => Err(eyre!( - "Console is configured but is not a PTY console, unsupported console type" - )), - } - } else { - Err(eyre!("VM does not have a console configured nor supported")) - } + pub fn console_path(&self) -> PathBuf { + self.runtime_dir().join(CONSOLE_SOCKET_FILE_NAME) } - pub async fn debug_console_path(&self) -> Result { - let vminfo = self.info().await?; - if let Some(debug_console) = vminfo.config.debug_console { - match debug_console.mode { - models::debug_console_config::Mode::Pty => { - debug!( - vm_id = self.vm_id(), - "VM has PTY debug console configured, returning PTY path" - ); - let console_path = debug_console.file.ok_or_else(|| { - eyre!("Debug console config is missing file path for PTY console") - })?; - Ok(console_path.into()) - } - _ => Err(eyre!( - "Debug console is configured but is not a PTY console, unsupported console type" - )), - } - } else { - Err(eyre!( - "VM does not have a debug console configured nor supported" - )) - } + pub fn debug_console_path(&self) -> PathBuf { + self.runtime_dir().join(DEBUG_CONSOLE_SOCKET_FILE_NAME) } pub async fn open_debug_console(&self) -> Result { - let console_path = self.debug_console_path().await?; - let file = OpenOptions::new() - .read(true) - .write(true) - .open(&console_path) + let socket_path = self.debug_console_path(); + tokio::net::UnixStream::connect(&socket_path) + .await .wrap_err_with(|| { eyre!( - "Failed to open PTY debug console device for {} at {}", + "Failed to connect to debug console socket for {} at {}", self.vm_id(), - console_path.display() + socket_path.display() ) - })?; - Ok(tokio::fs::File::from_std(file)) + }) } /// Spawn a new CH process and create a VMInstance for it. @@ -383,7 +338,12 @@ impl VMInstance { /// Gracefully shutdown the VM and VMM, then clean up runtime state. pub async fn destroy(&self) -> Result<()> { + info!( + vm_id = self.vm_id(), + "Destroying VM instance, shutting down VM and cleaning up runtime state" + ); if let Ok(info) = self.info().await { + trace!(vm_id = self.vm_id(), state = ?info.state, "Checking VM state before destroy"); if matches!( info.state, models::vm_info::State::Running | models::vm_info::State::Paused @@ -392,6 +352,7 @@ impl VMInstance { self.shutdown().await?; } } + let provisioner = super::provisioning::default_provisioner(); let vm_config = self diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index be8a420..182757b 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -41,20 +41,30 @@ impl ProvisioningHook for CHMachineProvisioningHook { fn after_start(&self, vmid: &str, _config: &VmConfig, pid: i32) -> HookFuture<'_> { let vmid = vmid.to_string(); Box::pin(async move { + if pid == 0 { + tracing::warn!( + vmid, + "Skipping systemd-machined registration: PID is 0 (service not yet active)" + ); + return Ok(()); + } tracing::info!(vmid, pid, "Registering machine with systemd-machined"); let runtime_dir = VMInstance::runtime_dir_for(&vmid); - let manager = get_manager_proxy().await?; - let res = manager - .register_machine( - vmid.clone(), - Vec::new(), - SERVICE_CLASS.to_string(), - "vm".to_string(), - pid as u32, - runtime_dir.display().to_string(), - ) - .await - .wrap_err("Failed to register machine with systemd-machined"); + let res = async { + let manager = get_manager_proxy().await?; + manager + .register_machine( + vmid.clone(), + Vec::new(), + SERVICE_CLASS.to_string(), + "vm".to_string(), + pid as u32, + runtime_dir.display().to_string(), + ) + .await + .wrap_err("Failed to register machine with systemd-machined") + } + .await; if let Err(e) = res { tracing::error!(vmid, error = ?e, "Failed to register machine with systemd-machined"); @@ -69,13 +79,15 @@ impl ProvisioningHook for CHMachineProvisioningHook { let vmid = vmid.to_string(); Box::pin(async move { tracing::info!(vmid, "Unregistering machine from systemd-machined"); - let manager = get_manager_proxy().await?; - let res = manager - .unregister_machine(vmid.clone()) - .await - .wrap_err("Failed to unregister machine from systemd-machined"); + let res = async { + let manager = get_manager_proxy().await?; + manager + .unregister_machine(vmid.clone()) + .await + .wrap_err("Failed to unregister machine from systemd-machined") + } + .await; - // don't fail if hook fails, just log the error if let Err(e) = res { tracing::error!(vmid, error = ?e, "Failed to unregister machine from systemd-machined"); } diff --git a/odorobo-agent/src/state/provisioning/systemd/proxy.rs b/odorobo-agent/src/state/provisioning/systemd/proxy.rs index 1c58b36..c5e28af 100644 --- a/odorobo-agent/src/state/provisioning/systemd/proxy.rs +++ b/odorobo-agent/src/state/provisioning/systemd/proxy.rs @@ -5,7 +5,8 @@ use crate::util::{systemd_instance_unit_name, zbus_system_connection}; /// using systemd. /// /// All it should do is simply just start and stop systemd services with the correct parameters -use stable_eyre::{Result, eyre::WrapErr}; +use stable_eyre::{Result, eyre::WrapErr, eyre::eyre}; +use tokio::time::{Duration, sleep}; use tracing::trace; use zbus::Connection; use zbus_systemd::systemd1::{ManagerProxy, ServiceProxy}; @@ -56,13 +57,63 @@ pub async fn start_instance(vmid: &str) -> Result { .await .wrap_err_with(|| format!("Failed to start systemd unit {unit_name}"))?; - let service = service_proxy(&connection, &unit_name).await?; - let pid = service - .main_pid() + let unit_path = manager + .load_unit(unit_name.clone()) .await - .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; + .wrap_err_with(|| format!("Failed to load systemd unit {unit_name}"))?; - Ok(pid as i32) + let unit = zbus_systemd::systemd1::UnitProxy::builder(&connection) + .path(unit_path.clone()) + .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? + .build() + .await + .wrap_err_with(|| format!("Failed to create unit proxy for systemd unit {unit_name}"))?; + + let service = zbus_systemd::systemd1::ServiceProxy::builder(&connection) + .path(&unit_path) + .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? + .build() + .await + .wrap_err_with(|| format!("Failed to create service proxy for systemd unit {unit_name}"))?; + + // start_unit enqueues a job but doesn't wait for it to complete. + // Poll until the service reaches a terminal state so we can return a real PID. + const MAX_WAIT: u32 = 30; + for attempt in 0..MAX_WAIT { + let state = unit + .active_state() + .await + .wrap_err_with(|| format!("Failed to get ActiveState for {unit_name}"))?; + + trace!( + ?unit_name, + ?state, + attempt, + "Waiting for systemd unit to start" + ); + + match state.as_str() { + "active" => { + let pid = service + .main_pid() + .await + .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; + return Ok(pid as i32); + } + "failed" => { + return Err(eyre!("Systemd unit {unit_name} failed to start")); + } + _ => {} + } + + if attempt < MAX_WAIT - 1 { + sleep(Duration::from_millis(500)).await; + } + } + + Err(eyre!( + "Systemd unit {unit_name} did not become active within {MAX_WAIT} attempts" + )) } #[tracing::instrument] diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs index 7c30eb4..914446b 100644 --- a/odorobo-agent/src/state/transform/console.rs +++ b/odorobo-agent/src/state/transform/console.rs @@ -15,22 +15,36 @@ impl ConfigTransform for ConsoleTransform { let runtime_path = VMInstance::runtime_dir_for(vmid); trace!("Applying ConsoleTransform"); config.console = Some(ConsoleConfig { - mode: cloud_hypervisor_client::models::console_config::Mode::Pty, + mode: cloud_hypervisor_client::models::console_config::Mode::Off, ..Default::default() }); - - config.debug_console = Some(DebugConsoleConfig { - mode: cloud_hypervisor_client::models::debug_console_config::Mode::Pty, - file: Some(format!("{}/debug_console.sock", runtime_path.display())), + // note: console passthrough is kinda janky and breaks live migration, needs a way to fix this + // + // todo: TTY mode also doesn't work well with systemd, need to figure out a good way to + // remotely attach TTY on boot without breaking systemd or live migration + // + // consider some virtual GPU device, but CH doesn't have QXL or virtio-gpu so idk + config.serial = Some(ConsoleConfig { + mode: cloud_hypervisor_client::models::console_config::Mode::Tty, + socket: Some(format!("{}/console.sock", runtime_path.display())), ..Default::default() }); - config.vsock = Some(cloud_hypervisor_client::models::VsockConfig { - cid: 3, - id: Some("odorobo-vsock".into()), - socket: format!("{}/vsock.sock", runtime_path.display()), - ..Default::default() - }); + // config.debug_console = Some(DebugConsoleConfig { + // mode: cloud_hypervisor_client::models::debug_console_config::Mode::Pty, + // file: Some(format!("{}/debug_console.sock", runtime_path.display())), + // ..Default::default() + // }); + + // TODO: fix vsock support + // currently it breaks live migration... + + // config.vsock = Some(cloud_hypervisor_client::models::VsockConfig { + // cid: 3, + // id: Some("odorobo-vsock".into()), + // socket: format!("{}/vsock.sock", runtime_path.display()), + // ..Default::default() + // }); Ok(()) } } diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 9c451de..5237947 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -10,8 +10,8 @@ OnFailure=odorobo-ch-failed@%i.service [Service] Type=simple -User=odorobo -Group=odorobo +User=root +Group=root RuntimeDirectory=odorobo/vms/%i RuntimeDirectoryMode=0770 @@ -22,7 +22,7 @@ ConfigurationDirectoryMode=0750 ExecStartPre=/usr/local/libexec/odorobo-preflight %i # the actual VM process -ExecStart=/usr/bin/cloud-hypervisor \ +ExecStart=/usr/bin/cloud-hypervisor -v \ --api-socket /run/odorobo/vms/%i/ch.sock ExecStartPost=/usr/local/libexec/odorobo-postflight %i @@ -40,8 +40,8 @@ TasksAccounting=yes # Security hardening NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict +# PrivateTmp=yes +# ProtectSystem=strict ProtectHome=yes ProtectKernelTunables=yes ProtectControlGroups=yes @@ -54,7 +54,7 @@ RestrictRealtime=no AmbientCapabilities=CAP_NET_ADMIN # Paths CH legitimately needs to write -ReadWritePaths=/run/odorobo/vms/%i /var/log/odorobo /etc/odorobo /var/lib/odorobo +# ReadWritePaths=/run/odorobo/vms/%i /var/log/odorobo /etc/odorobo /var/lib/odorobo # KVM device access DeviceAllow=/dev/kvm rw diff --git a/systemd/scripts/odorobo-preflight b/systemd/scripts/odorobo-preflight index 1251db0..bda8065 100644 --- a/systemd/scripts/odorobo-preflight +++ b/systemd/scripts/odorobo-preflight @@ -4,4 +4,4 @@ VMID=$1 if [ -z "$VMID" ]; then echo "Usage: $0 " exit 1 -fi \ No newline at end of file +fi From 64afbe5e8fd5059802873bf2a06dbeba1ab1e451 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 3 Apr 2026 17:46:35 +0700 Subject: [PATCH 032/189] go back to pty, but serial connections instead This has been proven to work fine for some reason, so console passthrough seems to work with PTY --- HACKING.md | 20 ++++---- README.md | 38 +++++++++++++-- docs/console.md | 45 +++++++----------- odorobo-agent/src/state/devices.rs | 30 ++++++++++++ odorobo-agent/src/state/instance.rs | 49 ++++++++------------ odorobo-agent/src/state/mod.rs | 1 + odorobo-agent/src/state/transform/console.rs | 9 ++-- 7 files changed, 113 insertions(+), 79 deletions(-) create mode 100644 odorobo-agent/src/state/devices.rs diff --git a/HACKING.md b/HACKING.md index cede486..ad1e6eb 100644 --- a/HACKING.md +++ b/HACKING.md @@ -20,25 +20,21 @@ python scripts/vsock.py my-vm Fyra Stack production images should just have a management agent to talk over vsock and do other stuff, but this is a quick way to get a shell over vsock for testing and debugging. -## serial console socket +## serial console (PTY) -Cloud Hypervisor is configured to expose the serial console as a Unix domain socket at a stable, predictable path: +Cloud Hypervisor allocates a PTY for the serial console. The PTY path is reported via the CH API after the VM is created. -``` -/run/odorobo/vms//console.sock +```bash +odoroboctl info my-vm ``` -Connect directly on the host with socat: +Look at the `config.serial` section of the output and find the `path` field. This is the PTY on the host connected to the VM's serial console. Connect to it with `screen` or any other terminal program: ```bash -# raw output only (useful for scripting or piping) -socat - UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock - -# interactive session with local terminal in raw mode -socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock +screen /dev/pts/N ``` -Press `Ctrl-]` then `q` to exit socat in interactive mode, or just close the terminal. +Where `N` is the number from the `path` field. Alternatively, use the agent's WebSocket console proxy from any machine that can reach the agent: @@ -46,4 +42,4 @@ Alternatively, use the agent's WebSocket console proxy from any machine that can websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -The socket path is stable across live migrations — after a migration completes, reconnect to the same path on the destination node. \ No newline at end of file +Note: the PTY path changes every time the VM is restarted or the VMM process is recreated. It is not stable across live migrations. \ No newline at end of file diff --git a/README.md b/README.md index b6552ba..f08ea77 100644 --- a/README.md +++ b/README.md @@ -67,19 +67,20 @@ Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/clo odoroboctl create my-vm --boot ./my-vm.json ``` -Now the VM should be running. You can connect to the VM's serial console directly on the host: +Now the VM should be running. You can connect to the VM's serial console via the agent's WebSocket proxy: ```bash -socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock +websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -Or connect remotely via the agent's WebSocket proxy, for example with `websocat`: +To connect directly on the host, look up the PTY path from the VM info: ```bash -websocat --binary ws://127.0.0.1:8890/vms/my-vm/console +odoroboctl info my-vm # find config.serial.path, e.g. /dev/pts/3 +screen /dev/pts/3 ``` -See [docs/console.md](docs/console.md) for serial console WebSocket usage and integration details. +See [docs/console.md](docs/console.md) for WebSocket console usage and integration details. For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API @@ -93,6 +94,33 @@ To start a live migration, you will first need to spawn a VM on the destination odoroboctl spawn my-vm-dest ``` +Now, on the same destination VM, start accepting migrations: + +```bash +odoroboctl migrate-receive my-vm-dest +``` + +You will now receive a response with the listening address and port for the destination machine. Use this information to start the migration from the source node. + +```json +{"listening_address":"tcp:0.0.0.0:49152"} +``` + +Replace `0.0.0.0` with the actual IP address of the destination node, and use the provided port (e.g. `49152`) + +Finally, start migration from the source node: + +```bash +odoroboctl migrate-send my-vm-source tcp::49152 +``` + +This will start the live migration process. The source VM will continue running until the final switchover phase, at which point it will be paused, the remaining state will be transferred to the destination, and then the destination VM will be resumed. + +You will have to manually manage networking and storage for the VM during migration, as Odorobo does not currently have any built-in network or storage management features. Migrations may fail if the networking configuration is unmigratable. + +This part is currently out-of-scope for Odorobo, as the orchestrator should be responsible for coordinating with the network and storage layers to ensure that the VM's resources are available on the destination node before starting migration. + +Odorobo however has support for custom pre and post-migration hooks, which can be used to implement custom logic before and after migration, such as preparing the destination node's network and storage configuration, or cleaning up the source node after migration. See `odorobo-agent/src/state/provisioning/hooks` for more details on how to implement and configure lifecycle hooks. ## Security notes diff --git a/docs/console.md b/docs/console.md index c70fa46..277d157 100644 --- a/docs/console.md +++ b/docs/console.md @@ -1,28 +1,28 @@ # Console API -## Serial console over WebSocket +## PTY over WebSocket The agent exposes a VM console bridge at: `GET /vms/{vmid}/console` -This endpoint upgrades to a WebSocket and proxies the VM's serial console in both directions. The console is backed by a Unix domain socket at `/run/odorobo/vms/{vmid}/console.sock`, which Cloud Hypervisor manages directly via its serial console socket mode. +This endpoint upgrades to a WebSocket and proxies the VM's serial console PTY in both directions. Cloud Hypervisor allocates a PTY for the serial console; the agent looks up the PTY path via the CH API and opens it on behalf of the client. ## Connection behavior - Connect with a standard WebSocket client to `ws://:8890/vms//console` -- The upgrade succeeds only if the VM exists and the agent can connect to its console socket +- The upgrade succeeds only if the VM exists and the agent can open its console PTY - If the VM does not exist, the HTTP request returns `404` -- If the console socket cannot be opened, the HTTP request returns `500` +- If the PTY cannot be opened, the HTTP request returns `500` - After the upgrade completes, terminal bytes flow over the socket until either side disconnects ## Frame semantics - Client -> VM: - - WebSocket `Binary` frames are written to the console socket as-is + - WebSocket `Binary` frames are written to the PTY as-is - WebSocket `Text` frames are reserved for JSON control messages - VM -> Client: - - Console output is sent back as WebSocket `Binary` frames + - PTY output is sent back as WebSocket `Binary` frames - Server control or protocol errors are sent as WebSocket `Text` frames containing JSON - Control frames: - WebSocket `Ping` receives a `Pong` @@ -32,19 +32,19 @@ In practice, treat this as a raw byte stream carried over WebSocket frames. ## Resize message -The resize control message is accepted but has no effect with socket-backed serial consoles: +Clients resize the PTY by sending a JSON text frame like: ```json {"type":"resize","cols":120,"rows":40} ``` -Optional pixel dimensions are also accepted: +Optional pixel dimensions are also supported: ```json {"type":"resize","cols":120,"rows":40,"x_pixels":960,"y_pixels":720} ``` -Unlike PTY-backed consoles, a Unix socket has no associated terminal window size. `TIOCSWINSZ` is not applicable here, so the guest cannot observe a resize event through this mechanism. Terminal size negotiation must be handled at the application layer if needed. +The agent applies the new TTY window size on the host PTY with `TIOCSWINSZ`, so the guest can observe the updated size through normal terminal mechanisms. ## Reset-session message @@ -54,7 +54,7 @@ Clients can ask the agent to try to return the console to a fresh login-like sta {"type":"reset_session"} ``` -This sends a conservative control sequence to the console: +This sends a conservative control sequence to the PTY: - `Ctrl-C` to interrupt a foreground shell command if possible - `Enter` to try to land on a clean prompt @@ -64,17 +64,16 @@ This is best-effort only. The guest decides what those bytes mean. ## Important implementation notes -- Do not assume one terminal message maps to one WebSocket frame; console output is chunked arbitrarily +- Do not assume one terminal message maps to one WebSocket frame; PTY output is chunked arbitrarily - Send terminal input as binary bytes, not text frames - Text frames should be treated as a small control channel for messages like resize requests, reset requests, and error events - If the server receives an invalid control message, it responds with a JSON text frame like `{"type":"error","message":"..."}` and keeps the session open - This API is transport-only; terminal emulation, ANSI parsing, scrollback, and rendering are client responsibilities -- The `resize` message is accepted but has no effect; the console socket has no associated terminal size -- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on the serial device -- `Ctrl-D` only causes a logout when the foreground process interprets it as EOF +- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on `hvc0` +- On serial-backed Linux sessions, `Ctrl-D` only causes a logout when the foreground process interprets it as EOF - If the guest is running `vim`, `less`, a full-screen app, or a raw-mode program, `reset_session` may not produce a fresh login prompt - For deterministic fresh sessions, configure the guest to respawn `getty` on the console device after shell exit -- The console socket at `/run/odorobo/vms/{vmid}/console.sock` is managed by Cloud Hypervisor and survives live migration; the WebSocket connection will break during migration but the socket path remains stable on the destination node +- The PTY path changes if the VM is restarted or migrated; the WebSocket connection must be re-established after migration ## Browser example @@ -127,22 +126,10 @@ function resetSession() { } ``` -## CLI examples - -Connect via the WebSocket bridge with websocat: +## CLI example with websocat ```bash websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -Connect directly to the console socket on the host (useful for debugging): - -```bash -# raw output only -socat - UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock - -# with local terminal in raw mode (proper interactive session) -socat file:`tty`,raw,echo=0 UNIX-CONNECT:/run/odorobo/vms/my-vm/console.sock -``` - -The websocat approach works from any machine that can reach the agent. The socat approach requires local host access but bypasses the agent entirely and is useful for low-level debugging or when the agent is not running. +This is the simplest way to verify the bridge works before integrating it into a browser terminal such as xterm.js. diff --git a/odorobo-agent/src/state/devices.rs b/odorobo-agent/src/state/devices.rs new file mode 100644 index 0000000..f9e4e8e --- /dev/null +++ b/odorobo-agent/src/state/devices.rs @@ -0,0 +1,30 @@ +use crate::state::VMInstance; +use cloud_hypervisor_client::{ + apis::DefaultApi, + models::{DeviceConfig, NetConfig, PciDeviceInfo, VmRemoveDevice}, +}; +use stable_eyre::{Result, eyre::Context}; + +impl VMInstance { + pub async fn add_device(&self, device_config: DeviceConfig) -> Result { + self.conn() + .vm_add_device_put(device_config) + .await + .wrap_err_with(|| format!("Failed to add device to VM {}", self.id)) + } + + pub async fn remove_device(&self, remove_device: VmRemoveDevice) -> Result<()> { + self.conn() + .vm_remove_device_put(remove_device) + .await + .wrap_err_with(|| format!("Failed to remove device from VM {}", self.id)) + } + + pub async fn add_net(&self, net_config: NetConfig) -> Result { + self.conn() + .vm_add_net_put(net_config) + .await + .wrap_err_with(|| format!("Failed to add network device to VM {}", self.id)) + } + +} diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 063b0e9..cb784b1 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -22,10 +22,8 @@ use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; -const CONSOLE_SOCKET_FILE_NAME: &str = "console.sock"; -const DEBUG_CONSOLE_SOCKET_FILE_NAME: &str = "debug_console.socket"; pub const VMS_DIR_NAME: &str = "vms"; -pub type ConsoleStream = tokio::net::UnixStream; +pub type ConsoleStream = tokio::fs::File; const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; @@ -216,7 +214,7 @@ impl VMInstance { .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_ROOT_DIR)) } - fn conn(&self) -> SocketBasedApiClient { + pub fn conn(&self) -> SocketBasedApiClient { cloud_hypervisor_client::socket_based_api_client(self.ch_socket_path.clone()) } @@ -224,16 +222,30 @@ impl VMInstance { &self.id } + /// Returns the PTY path for this VM's serial console by querying the CH API. + pub async fn console_path(&self) -> Result { + let info = self.info().await?; + let path = info + .config + .serial + .and_then(|s| s.path) + .ok_or_else(|| eyre!("No serial console PTY path available for {}", self.vm_id()))?; + Ok(PathBuf::from(path)) + } + /// Opens the PTY console device for this VM and returns a connected stream. pub async fn open_console(&self) -> Result { - let socket_path = self.console_path(); - tokio::net::UnixStream::connect(&socket_path) + let pty_path = self.console_path().await?; + tokio::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&pty_path) .await .wrap_err_with(|| { eyre!( - "Failed to connect to console socket for {} at {}", + "Failed to open console PTY for {} at {}", self.vm_id(), - socket_path.display() + pty_path.display() ) }) } @@ -277,27 +289,6 @@ impl VMInstance { .wrap_err(eyre!("Failed to ping VM {}", self.vm_id())) } - pub fn console_path(&self) -> PathBuf { - self.runtime_dir().join(CONSOLE_SOCKET_FILE_NAME) - } - - pub fn debug_console_path(&self) -> PathBuf { - self.runtime_dir().join(DEBUG_CONSOLE_SOCKET_FILE_NAME) - } - - pub async fn open_debug_console(&self) -> Result { - let socket_path = self.debug_console_path(); - tokio::net::UnixStream::connect(&socket_path) - .await - .wrap_err_with(|| { - eyre!( - "Failed to connect to debug console socket for {} at {}", - self.vm_id(), - socket_path.display() - ) - }) - } - /// Spawn a new CH process and create a VMInstance for it. /// /// Waits for the socket to become available (polls up to ~30 seconds). diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 669f652..7f56ead 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -5,6 +5,7 @@ mod api; mod instance; +mod devices; mod provisioning; mod transform; diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs index 914446b..aec9d17 100644 --- a/odorobo-agent/src/state/transform/console.rs +++ b/odorobo-agent/src/state/transform/console.rs @@ -19,14 +19,15 @@ impl ConfigTransform for ConsoleTransform { ..Default::default() }); // note: console passthrough is kinda janky and breaks live migration, needs a way to fix this - // + // // todo: TTY mode also doesn't work well with systemd, need to figure out a good way to // remotely attach TTY on boot without breaking systemd or live migration - // + // // consider some virtual GPU device, but CH doesn't have QXL or virtio-gpu so idk config.serial = Some(ConsoleConfig { - mode: cloud_hypervisor_client::models::console_config::Mode::Tty, - socket: Some(format!("{}/console.sock", runtime_path.display())), + mode: cloud_hypervisor_client::models::console_config::Mode::Pty, + // file: Some(format!("{}/serial", runtime_path.display())), + // socket: Some(format!("{}/console.sock", runtime_path.display())), ..Default::default() }); From 8c1fc668009c344fc71071fafe3eba764ad35add Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 3 Apr 2026 18:28:43 +0700 Subject: [PATCH 033/189] Fix console PTY access and WebSocket proxy --- HACKING.md | 2 +- README.md | 2 +- docs/console.md | 1 + odorobo-agent/src/api/console.rs | 140 +++++++++++++++---- odorobo-agent/src/state/instance.rs | 20 +-- odorobo-agent/src/state/transform/console.rs | 2 +- 6 files changed, 127 insertions(+), 40 deletions(-) diff --git a/HACKING.md b/HACKING.md index ad1e6eb..3f24a23 100644 --- a/HACKING.md +++ b/HACKING.md @@ -28,7 +28,7 @@ Cloud Hypervisor allocates a PTY for the serial console. The PTY path is reporte odoroboctl info my-vm ``` -Look at the `config.serial` section of the output and find the `path` field. This is the PTY on the host connected to the VM's serial console. Connect to it with `screen` or any other terminal program: +Look at the `config.serial` section of the output and find the `file` field. This is the PTY on the host connected to the VM's serial console. Connect to it with `screen` or any other terminal program: ```bash screen /dev/pts/N diff --git a/README.md b/README.md index f08ea77..d3628ce 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ websocat --binary ws://127.0.0.1:8890/vms/my-vm/console To connect directly on the host, look up the PTY path from the VM info: ```bash -odoroboctl info my-vm # find config.serial.path, e.g. /dev/pts/3 +odoroboctl info my-vm # find config.serial.file, e.g. /dev/pts/3 screen /dev/pts/3 ``` diff --git a/docs/console.md b/docs/console.md index 277d157..5b7a17d 100644 --- a/docs/console.md +++ b/docs/console.md @@ -22,6 +22,7 @@ This endpoint upgrades to a WebSocket and proxies the VM's serial console PTY in - WebSocket `Binary` frames are written to the PTY as-is - WebSocket `Text` frames are reserved for JSON control messages - VM -> Client: + - A `{"type":"connected","vm_id":"..."}` text frame is sent immediately on upgrade before any PTY data - PTY output is sent back as WebSocket `Binary` frames - Server control or protocol errors are sent as WebSocket `Text` frames containing JSON - Control frames: diff --git a/odorobo-agent/src/api/console.rs b/odorobo-agent/src/api/console.rs index de9dd2c..0c484cd 100644 --- a/odorobo-agent/src/api/console.rs +++ b/odorobo-agent/src/api/console.rs @@ -8,10 +8,11 @@ use axum::{ use futures_util::SinkExt; use serde::{Deserialize, Serialize}; use stable_eyre::{Result, eyre::eyre}; +use std::io::{self, Read, Write}; use std::os::fd::AsRawFd; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::unix::AsyncFd; use tokio::time::{Duration, sleep}; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use super::error::ApiError; use crate::state::{ConsoleStream, VMInstance}; @@ -22,7 +23,10 @@ pub async fn console_stream( ) -> Result { let vmid = vmid.0; let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.clone() })?; - let console = vm.open_console().await.map_err(|e| ApiError::ConsoleFailed { msg: e.to_string() })?; + let console = vm + .open_console() + .await + .map_err(|e| ApiError::ConsoleFailed { msg: e.to_string() })?; Ok(ws.on_upgrade(move |socket| proxy_console_socket(vmid, socket, console))) } @@ -44,11 +48,30 @@ enum ConsoleControlMessage { #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ConsoleServerMessage<'a> { + Connected { vm_id: &'a str }, Error { message: &'a str }, } -async fn proxy_console_socket(vm_id: String, mut socket: WebSocket, mut console: ConsoleStream) { - match proxy_console(&mut socket, &mut console).await { +async fn proxy_console_socket(vm_id: String, mut socket: WebSocket, console: ConsoleStream) { + if let Err(err) = send_console_event( + &mut socket, + ConsoleServerMessage::Connected { vm_id: &vm_id }, + ) + .await + { + warn!(vm_id, ?err, "Failed to send connected event"); + return; + } + + let console_fd = match AsyncFd::new(console) { + Ok(fd) => fd, + Err(err) => { + warn!(vm_id, ?err, "Failed to wrap console in AsyncFd"); + return; + } + }; + + match proxy_console(&mut socket, console_fd).await { Ok(()) => debug!(vm_id, "Console websocket disconnected"), Err(err) => warn!(vm_id, ?err, "Console websocket proxy failed"), } @@ -56,28 +79,76 @@ async fn proxy_console_socket(vm_id: String, mut socket: WebSocket, mut console: let _ = socket.close().await; } -async fn proxy_console(socket: &mut WebSocket, console: &mut ConsoleStream) -> Result<()> { +async fn proxy_console(socket: &mut WebSocket, mut console: AsyncFd) -> Result<()> { let mut buf = [0_u8; 8192]; loop { + trace!("select waiting on ws.recv() and console.read()"); tokio::select! { message = socket.recv() => { + trace!("ws.recv() returned: {:?}", message.as_ref().map(|m| match m { + Ok(Message::Binary(d)) => format!("Binary({}b)", d.len()), + Ok(Message::Text(t)) => format!("Text({}b)", t.len()), + Ok(Message::Close(_)) => "Close".into(), + Ok(Message::Ping(_)) => "Ping".into(), + Ok(Message::Pong(_)) => "Pong".into(), + Err(e) => format!("Error: {}", e), + })); match message { - Some(Ok(Message::Binary(data))) => console.write_all(&data).await?, - Some(Ok(Message::Text(text))) => handle_console_control(socket, console, text.to_string()).await?, - Some(Ok(Message::Close(_))) | None => break, - Some(Ok(Message::Ping(payload))) => socket.send(Message::Pong(payload)).await?, - Some(Ok(Message::Pong(_))) => {} - Some(Err(err)) => return Err(err.into()), + Some(Ok(Message::Binary(data))) => { + trace!(bytes = data.len(), "ws -> pty (binary)"); + let _ = console.writable().await?; + let data_copy = data.to_vec(); + let write_result = tokio::task::block_in_place(|| { + console.get_mut().write_all(&data_copy)?; + console.get_mut().flush()?; + Ok::<(), io::Error>(()) + }); + write_result?; + trace!("write and flush done"); + } + Some(Ok(Message::Text(text))) => { + trace!(len = text.len(), "ws -> pty (text/control)"); + handle_console_control(socket, &mut console, text.to_string()).await? + } + Some(Ok(Message::Close(_))) | None => { + trace!("ws close or none"); + break; + } + Some(Ok(Message::Ping(payload))) => { + trace!("ws ping"); + socket.send(Message::Pong(payload)).await? + } + Some(Ok(Message::Pong(_))) => { + trace!("ws pong"); + } + Some(Err(err)) => { + trace!(?err, "ws error"); + return Err(err.into()); + } } } - read = console.read(&mut buf) => { - let read = read?; - if read == 0 { - break; + read_result = async { + let _ = console.readable().await?; + tokio::task::block_in_place(|| { + console.get_mut().read(&mut buf) + }) + } => { + match read_result { + Ok(n) => { + if n == 0 { + trace!("pty read returned 0 bytes"); + break; + } + trace!(bytes = n, "pty -> ws"); + socket.send(Message::Binary(buf[..n].to_vec().into())).await?; + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + trace!("pty read would block, retrying"); + // Continue select loop to try again + } + Err(e) => return Err(e.into()), } - - socket.send(Message::Binary(buf[..read].to_vec().into())).await?; } } } @@ -87,7 +158,7 @@ async fn proxy_console(socket: &mut WebSocket, console: &mut ConsoleStream) -> R async fn handle_console_control( socket: &mut WebSocket, - console: &mut ConsoleStream, + console: &mut AsyncFd, raw_message: String, ) -> Result<()> { let message: ConsoleControlMessage = match serde_json::from_str(&raw_message) { @@ -113,13 +184,15 @@ async fn handle_console_control( } => { if let Err(err) = resize_console(console, cols, rows, x_pixels, y_pixels) { let message = format!("Failed to resize console: {err}"); - send_console_event(socket, ConsoleServerMessage::Error { message: &message }).await?; + send_console_event(socket, ConsoleServerMessage::Error { message: &message }) + .await?; } } ConsoleControlMessage::ResetSession => { if let Err(err) = reset_console_session(console).await { let message = format!("Failed to reset console session: {err}"); - send_console_event(socket, ConsoleServerMessage::Error { message: &message }).await?; + send_console_event(socket, ConsoleServerMessage::Error { message: &message }) + .await?; } } } @@ -134,7 +207,7 @@ async fn send_console_event(socket: &mut WebSocket, event: ConsoleServerMessage< } fn resize_console( - console: &ConsoleStream, + console: &AsyncFd, cols: u16, rows: u16, x_pixels: u16, @@ -159,19 +232,28 @@ fn resize_console( Ok(()) } -async fn reset_console_session(console: &mut ConsoleStream) -> Result<()> { +async fn reset_console_session(console: &mut AsyncFd) -> Result<()> { const STEP_DELAY: Duration = Duration::from_millis(75); - console.write_all(&[0x03]).await?; - console.flush().await?; + macro_rules! write_to_console { + ($data:expr) => {{ + let _ = console.writable().await?; + let data = $data.to_vec(); + tokio::task::block_in_place(|| { + console.get_mut().write_all(&data)?; + console.get_mut().flush()?; + Ok::<(), io::Error>(()) + })?; + }}; + } + + write_to_console!(&[0x03]); sleep(STEP_DELAY).await; - console.write_all(b"\r").await?; - console.flush().await?; + write_to_console!(b"\r"); sleep(STEP_DELAY).await; - console.write_all(&[0x04]).await?; - console.flush().await?; + write_to_console!(&[0x04]); Ok(()) } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index cb784b1..066c2fb 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -12,6 +12,7 @@ use stable_eyre::{ }; use std::{ env, fs, + os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, }; use thiserror::Error; @@ -23,7 +24,7 @@ use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; pub const VMS_DIR_NAME: &str = "vms"; -pub type ConsoleStream = tokio::fs::File; +pub type ConsoleStream = std::fs::File; const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; @@ -223,24 +224,27 @@ impl VMInstance { } /// Returns the PTY path for this VM's serial console by querying the CH API. + #[tracing::instrument] pub async fn console_path(&self) -> Result { + trace!("Getting console PTY path from CH API"); let info = self.info().await?; - let path = info - .config - .serial - .and_then(|s| s.path) - .ok_or_else(|| eyre!("No serial console PTY path available for {}", self.vm_id()))?; + let path = + info.config.serial.and_then(|s| s.file).ok_or_else(|| { + eyre!("No serial console PTY path available for {}", self.vm_id()) + })?; Ok(PathBuf::from(path)) } /// Opens the PTY console device for this VM and returns a connected stream. + #[tracing::instrument] pub async fn open_console(&self) -> Result { let pty_path = self.console_path().await?; - tokio::fs::OpenOptions::new() + trace!(pty_path = ?pty_path, "Opening console PTY device"); + fs::OpenOptions::new() .read(true) .write(true) + .custom_flags(libc::O_NONBLOCK) .open(&pty_path) - .await .wrap_err_with(|| { eyre!( "Failed to open console PTY for {} at {}", diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs index aec9d17..a62546d 100644 --- a/odorobo-agent/src/state/transform/console.rs +++ b/odorobo-agent/src/state/transform/console.rs @@ -1,4 +1,4 @@ -use cloud_hypervisor_client::models::{ConsoleConfig, DebugConsoleConfig, VmConfig}; +use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; use stable_eyre::Result; use tracing::trace; From 627d3bd40a1f4a6cc141f8ffc5dda2bc7988a52d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 3 Apr 2026 23:40:22 +0700 Subject: [PATCH 034/189] reload daemon on install --- justfile | 1 + odorobo-agent/src/api/console.rs | 36 ++++++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/justfile b/justfile index 39ab70a..d6bb82a 100644 --- a/justfile +++ b/justfile @@ -30,6 +30,7 @@ install_script: install_unit: install -Dm644 systemd/odorobo-ch@.service {{ SYSTEMD_UNITDIR }}/systemd/system/odorobo-ch@.service + systemctl daemon-reload || true install_agent: build_agent install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent diff --git a/odorobo-agent/src/api/console.rs b/odorobo-agent/src/api/console.rs index 0c484cd..5ccf9b5 100644 --- a/odorobo-agent/src/api/console.rs +++ b/odorobo-agent/src/api/console.rs @@ -83,20 +83,20 @@ async fn proxy_console(socket: &mut WebSocket, mut console: AsyncFd { - trace!("ws.recv() returned: {:?}", message.as_ref().map(|m| match m { - Ok(Message::Binary(d)) => format!("Binary({}b)", d.len()), - Ok(Message::Text(t)) => format!("Text({}b)", t.len()), - Ok(Message::Close(_)) => "Close".into(), - Ok(Message::Ping(_)) => "Ping".into(), - Ok(Message::Pong(_)) => "Pong".into(), - Err(e) => format!("Error: {}", e), - })); + // trace!("ws.recv() returned: {:?}", message.as_ref().map(|m| match m { + // Ok(Message::Binary(d)) => format!("Binary({}b)", d.len()), + // Ok(Message::Text(t)) => format!("Text({}b)", t.len()), + // Ok(Message::Close(_)) => "Close".into(), + // Ok(Message::Ping(_)) => "Ping".into(), + // Ok(Message::Pong(_)) => "Pong".into(), + // Err(e) => format!("Error: {}", e), + // })); match message { Some(Ok(Message::Binary(data))) => { - trace!(bytes = data.len(), "ws -> pty (binary)"); + // trace!(bytes = data.len(), "ws -> pty (binary)"); let _ = console.writable().await?; let data_copy = data.to_vec(); let write_result = tokio::task::block_in_place(|| { @@ -105,22 +105,22 @@ async fn proxy_console(socket: &mut WebSocket, mut console: AsyncFd(()) }); write_result?; - trace!("write and flush done"); + // trace!("write and flush done"); } Some(Ok(Message::Text(text))) => { - trace!(len = text.len(), "ws -> pty (text/control)"); + // trace!(len = text.len(), "ws -> pty (text/control)"); handle_console_control(socket, &mut console, text.to_string()).await? } Some(Ok(Message::Close(_))) | None => { - trace!("ws close or none"); + // trace!("ws close or none"); break; } Some(Ok(Message::Ping(payload))) => { - trace!("ws ping"); + // trace!("ws ping"); socket.send(Message::Pong(payload)).await? } Some(Ok(Message::Pong(_))) => { - trace!("ws pong"); + // trace!("ws pong"); } Some(Err(err)) => { trace!(?err, "ws error"); @@ -137,14 +137,14 @@ async fn proxy_console(socket: &mut WebSocket, mut console: AsyncFd { if n == 0 { - trace!("pty read returned 0 bytes"); + // trace!("pty read returned 0 bytes"); break; } - trace!(bytes = n, "pty -> ws"); + // trace!(bytes = n, "pty -> ws"); socket.send(Message::Binary(buf[..n].to_vec().into())).await?; } Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - trace!("pty read would block, retrying"); + // trace!("pty read would block, retrying"); // Continue select loop to try again } Err(e) => return Err(e.into()), From 23c7e19ecbed35d603222dfa5b1c688c80eb9dd0 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 19:28:05 +0700 Subject: [PATCH 035/189] add agent systemd service --- justfile | 1 + systemd/odorobo-agent.service | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 systemd/odorobo-agent.service diff --git a/justfile b/justfile index d6bb82a..46fd3ef 100644 --- a/justfile +++ b/justfile @@ -30,6 +30,7 @@ install_script: install_unit: install -Dm644 systemd/odorobo-ch@.service {{ SYSTEMD_UNITDIR }}/systemd/system/odorobo-ch@.service + install -Dm644 systemd/odorobo-agent.service {{ SYSTEMD_UNITDIR }}/systemd/system/odorobo-agent.service systemctl daemon-reload || true install_agent: build_agent diff --git a/systemd/odorobo-agent.service b/systemd/odorobo-agent.service new file mode 100644 index 0000000..d659de7 --- /dev/null +++ b/systemd/odorobo-agent.service @@ -0,0 +1,29 @@ +[Unit] +Description=Odorobo Agent +Documentation=https://github.com/FyraStack/odorobo +After=network-online.target +Wants=network-online.target +#Requires=odorobo-agent.service + +[Service] +Type=simple +User=root +Group=root + +RuntimeDirectory=odorobo/ +RuntimeDirectoryMode=1775 +LogsDirectory=odorobo +LogsDirectoryMode=0750 +ConfigurationDirectory=odorobo/ +ConfigurationDirectoryMode=0750 + +ExecStart=/usr/bin/odorobo-agent + + +Restart=always +RestartSec=3s +RestartPreventExitStatus=0 +TimeoutStopSec=30s + +[Install] +WantedBy=multi-user.target From 25b14653a73dd51ceabac10d023829a8e1471434 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 19:30:07 +0700 Subject: [PATCH 036/189] add cli build --- justfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 46fd3ef..fa85376 100644 --- a/justfile +++ b/justfile @@ -10,18 +10,21 @@ USRLIBDIR := PREFIX / "lib" SYSTEMD_UNITDIR := "/etc" -build: build_agent +build: build_agent build_cli build_agent: cargo build --release -p odorobo-agent +build_cli: + cargo build --release -p odoroboctl + build_agent_debug: cargo build -p odorobo-agent debug: build_agent_debug sudo target/debug/odorobo-agent -install: install_script install_unit install_agent +install: install_script install_unit install_agent install_cli install_script: install -Dm755 systemd/scripts/odorobo-preflight {{ LIBEXECDIR }}/odorobo-preflight @@ -36,5 +39,8 @@ install_unit: install_agent: build_agent install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent +install_cli: build_cli + install -Dm755 target/release/odoroboctl {{ BINDIR }}/odoroboctl + install_agent_debug: install -Dm755 target/debug/odorobo-agent {{ BINDIR }}/odorobo-agent From 543ebaabe5f1e56eb9951e44d722a0de8d8616b6 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 19:30:52 +0700 Subject: [PATCH 037/189] don't build before install --- justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index fa85376..3b39a76 100644 --- a/justfile +++ b/justfile @@ -36,10 +36,10 @@ install_unit: install -Dm644 systemd/odorobo-agent.service {{ SYSTEMD_UNITDIR }}/systemd/system/odorobo-agent.service systemctl daemon-reload || true -install_agent: build_agent +install_agent: install -Dm755 target/release/odorobo-agent {{ BINDIR }}/odorobo-agent -install_cli: build_cli +install_cli: install -Dm755 target/release/odoroboctl {{ BINDIR }}/odoroboctl install_agent_debug: From 876338cb70a10bb3f83447995aadec1c4cb52efd Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 20:17:35 +0700 Subject: [PATCH 038/189] refactor: use async_trait for dynamic dispatch traits --- Cargo.lock | 3 + HACKING.md | 11 ++- odorobo-agent/Cargo.toml | 2 + .../src/state/provisioning/hooks/machined.rs | 98 +++++++++---------- .../src/state/provisioning/hooks/mod.rs | 44 +++------ 5 files changed, 77 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ad184c..d988835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1531,6 +1531,7 @@ dependencies = [ name = "odorobo-agent" version = "0.1.0" dependencies = [ + "async-trait", "axum", "axum-extra", "axum_responses", @@ -1551,6 +1552,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "zbus", "zbus_systemd", ] @@ -2713,6 +2715,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/HACKING.md b/HACKING.md index 3f24a23..ebcda4e 100644 --- a/HACKING.md +++ b/HACKING.md @@ -42,4 +42,13 @@ Alternatively, use the agent's WebSocket console proxy from any machine that can websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -Note: the PTY path changes every time the VM is restarted or the VMM process is recreated. It is not stable across live migrations. \ No newline at end of file +Note: the PTY path changes every time the VM is restarted or the VMM process is recreated. It is not stable across live migrations. + +## `async_trait` usage + +While Rust 1.75 (2024) finally adds support for async functions in traits, Odorobo makes extensive usage +of dynamic dispatch for config hooks and config transformers, which are still not supported by Rust's async fn in traits. + +To work around this, Odorobo uses the `async_trait` crate to allow async functions in traits with dynamic dispatch. + +If your trait needs to do dynamic dispatch (e.g. for provisioning hooks), you must use `async_trait`. Else use normal async functions. diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 0d03da4..618e9d9 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -27,4 +27,6 @@ random-port = "0.1.1" if-addrs = "0.13" zbus = "5.14.0" zbus_systemd = { version = "0.26000.0", features = ["machine1", "systemd1", "zbus-async-tokio"] } +url = { version = "2.5.8", features = ["serde"] } +async-trait = "0.1.89" diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index 182757b..bef0966 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -1,8 +1,9 @@ //! systemd-machined integration hook for odorobo agent use crate::state::VMInstance; -use crate::state::provisioning::hooks::{HookFuture, ProvisioningHook}; +use crate::state::provisioning::hooks::ProvisioningHook; use crate::util::zbus_system_connection; +use async_trait::async_trait; use cloud_hypervisor_client::models::VmConfig; use stable_eyre::eyre::Context; use stable_eyre::{Result, eyre::eyre}; @@ -37,62 +38,57 @@ pub const SERVICE_CLASS: &str = "cloud-hypervisor"; pub struct CHMachineProvisioningHook; +#[async_trait] impl ProvisioningHook for CHMachineProvisioningHook { - fn after_start(&self, vmid: &str, _config: &VmConfig, pid: i32) -> HookFuture<'_> { - let vmid = vmid.to_string(); - Box::pin(async move { - if pid == 0 { - tracing::warn!( - vmid, - "Skipping systemd-machined registration: PID is 0 (service not yet active)" - ); - return Ok(()); - } - tracing::info!(vmid, pid, "Registering machine with systemd-machined"); - let runtime_dir = VMInstance::runtime_dir_for(&vmid); - let res = async { - let manager = get_manager_proxy().await?; - manager - .register_machine( - vmid.clone(), - Vec::new(), - SERVICE_CLASS.to_string(), - "vm".to_string(), - pid as u32, - runtime_dir.display().to_string(), - ) - .await - .wrap_err("Failed to register machine with systemd-machined") - } - .await; + async fn after_start(&self, vmid: &str, _config: &VmConfig, pid: i32) -> Result<()> { + if pid == 0 { + tracing::warn!( + vmid, + "Skipping systemd-machined registration: PID is 0 (service not yet active)" + ); + return Ok(()); + } + tracing::info!(vmid, pid, "Registering machine with systemd-machined"); + let runtime_dir = VMInstance::runtime_dir_for(vmid); + let res = async { + let manager = get_manager_proxy().await?; + manager + .register_machine( + vmid.to_string(), + Vec::new(), + SERVICE_CLASS.to_string(), + "vm".to_string(), + pid as u32, + runtime_dir.display().to_string(), + ) + .await + .wrap_err("Failed to register machine with systemd-machined") + } + .await; - if let Err(e) = res { - tracing::error!(vmid, error = ?e, "Failed to register machine with systemd-machined"); - tracing::warn!(vmid, "Continuing without systemd-machined registration"); - } + if let Err(e) = res { + tracing::error!(vmid, error = ?e, "Failed to register machine with systemd-machined"); + tracing::warn!(vmid, "Continuing without systemd-machined registration"); + } - Ok(()) - }) + Ok(()) } - fn before_stop(&self, vmid: &str, _config: &VmConfig) -> HookFuture<'_> { - let vmid = vmid.to_string(); - Box::pin(async move { - tracing::info!(vmid, "Unregistering machine from systemd-machined"); - let res = async { - let manager = get_manager_proxy().await?; - manager - .unregister_machine(vmid.clone()) - .await - .wrap_err("Failed to unregister machine from systemd-machined") - } - .await; + async fn before_stop(&self, vmid: &str, _config: &VmConfig) -> Result<()> { + tracing::info!(vmid, "Unregistering machine from systemd-machined"); + let res = async { + let manager = get_manager_proxy().await?; + manager + .unregister_machine(vmid.to_string()) + .await + .wrap_err("Failed to unregister machine from systemd-machined") + } + .await; - if let Err(e) = res { - tracing::error!(vmid, error = ?e, "Failed to unregister machine from systemd-machined"); - } + if let Err(e) = res { + tracing::error!(vmid, error = ?e, "Failed to unregister machine from systemd-machined"); + } - Ok(()) - }) + Ok(()) } } diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index 7a4fdd8..29778cf 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -7,48 +7,34 @@ //! They are different from transforms which are designed to modify the configuration itself //! to accomodate for the host environment, while hooks provide ways for the host itself //! to react to provisioning events and perform necessary setup/teardown actions. +use async_trait::async_trait; use cloud_hypervisor_client::models::VmConfig; use stable_eyre::Result; -use std::{future::Future, pin::Pin}; mod machined; -// hack: our own mini async-trait implementation -// because for some reason using async inside a dynamic dispatch trait -// like this causes the compiler to complain about -// not being able to make vtables for the trait, even though -// rust 2024 should have been supporting this... -// -// and I don't want to pull in the async-trait crate just for this -pub type HookFuture<'a> = Pin> + Send + 'a>>; +// Rust 1.75 does not support dyn async traits, we still need async_trait for this +#[async_trait] pub trait ProvisioningHook: Send + Sync { - fn before_start(&self, _vmid: &str, config: &VmConfig) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn before_start(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + Ok(()) } - fn after_start(&self, _vmid: &str, _config: &VmConfig, _pid: i32) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn after_start(&self, _vmid: &str, _config: &VmConfig, _pid: i32) -> Result<()> { + Ok(()) } - fn before_stop(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn before_stop(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + Ok(()) } - fn after_stop(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn after_stop(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + Ok(()) } - - fn before_boot(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn before_boot(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + Ok(()) } - fn after_boot(&self, _vmid: &str, _config: &VmConfig) -> HookFuture<'_> { - Box::pin(async { Ok(()) }) + async fn after_boot(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + Ok(()) } - - // fn before_destroy(&self, _vmid: &str) -> HookFuture<'_> { - // Box::pin(async { Ok(()) }) - // } - // fn after_destroy(&self, _vmid: &str) -> HookFuture<'_> { - // Box::pin(async { Ok(()) }) - // } } pub struct HookManager { From 631185b20a6f00fce2249ca3e64870c4f2cf339d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 20:55:48 +0700 Subject: [PATCH 039/189] implement storage URI transformers --- odorobo-agent/src/state/transform/mod.rs | 2 + .../src/state/transform/storage/file.rs | 30 +++++ .../src/state/transform/storage/mod.rs | 109 ++++++++++++++++++ .../src/state/transform/storage/rbd.rs | 109 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 odorobo-agent/src/state/transform/storage/file.rs create mode 100644 odorobo-agent/src/state/transform/storage/mod.rs create mode 100644 odorobo-agent/src/state/transform/storage/rbd.rs diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 65975ae..81a094f 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -7,6 +7,7 @@ pub trait ConfigTransform: Send + Sync { mod console; mod path_verify; +mod storage; pub use console::ConsoleTransform; pub use path_verify::PathVerify; @@ -41,6 +42,7 @@ pub fn apply_builtin_transforms(vmid: &str, config: &mut VmConfig) -> Result<()> TransformChain::new() .add(ConsoleTransform) .add(PathVerify) + .add(storage::StorageChain::default()) .then() .transform(vmid, config) } diff --git a/odorobo-agent/src/state/transform/storage/file.rs b/odorobo-agent/src/state/transform/storage/file.rs new file mode 100644 index 0000000..78f84be --- /dev/null +++ b/odorobo-agent/src/state/transform/storage/file.rs @@ -0,0 +1,30 @@ +//! Simple file URI storage backend for disk images, +//! resolves file:// URIs to local file paths without modification, +//! as if we're simply just stripping the prefix +//! e.g. `file:///path/to/disk.img` -> `/path/to/disk.img` + +use super::StorageBackend; +use async_trait::async_trait; +use stable_eyre::{Result, eyre::eyre}; +use std::path::PathBuf; +use url::Url; + +pub struct FileStorage; +#[async_trait] +impl StorageBackend for FileStorage { + fn scheme(&self) -> &'static str { + "file" + } + + async fn resolve(&self, uri: &Url) -> Result { + let path = uri + .to_file_path() + .map_err(|_| eyre!("Failed to convert file URI to path: '{}'", uri.as_str()))?; + Ok(path) + } + + async fn release(&self, _uri: &Url) -> Result<()> { + // No resources to release for file storage + Ok(()) + } +} diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo-agent/src/state/transform/storage/mod.rs new file mode 100644 index 0000000..69f5e1b --- /dev/null +++ b/odorobo-agent/src/state/transform/storage/mod.rs @@ -0,0 +1,109 @@ +use crate::state::transform::ConfigTransform; +use async_trait::async_trait; +use cloud_hypervisor_client::models::VmConfig; +use stable_eyre::Result; +use std::path::PathBuf; +use tracing::warn; +use url::Url; + +mod file; +mod rbd; + +/// A storage backend for Odorobo to resolve storage URIs to local paths. +/// +/// This allows Odorobo to actually convert a custom URI +/// scheme to a local block device or file path that can be mapped to a VM, +/// and allow them to be released when the VM is stopped. +/// +/// For example, a storage backend could resolve `rdb://pool/disk1` to `/run/odorobo/devices/pool/disk1` +/// and create a symlink to `/dev/rdbN` there, returning that path +/// when `resolve` is called. When `release` is called, the symlink and the resolved path can be cleaned up. +/// +/// This helps orchestrators to deal with storage management, by offloading the responsibility of attaching +/// LUNs to the agent, and letting the orchestrator just tell the agent to resolve a URI to a path, and release it when done. +/// +/// Additional metadata may also be stored in the storage backend, such as the original URI, to allow for other agent +/// instances to resolve the same URI properly, or for debugging purposes. This is up to the implementation of the storage backend. + +// using async_trait here because even with Rust 1.75, dyn in async traits do not work due to +// vtable issues +#[async_trait] +pub trait StorageBackend: Send + Sync { + /// The URI scheme this backend handles, e.g. `"rbd"`, `"file"`. Used for dispatch in `StorageChain`. + fn scheme(&self) -> &'static str; + + /// Resolves a URI to a local block device or file path for use in a VM disk config. + async fn resolve(&self, uri: &Url) -> Result; + + /// Releases resources associated with a previously resolved URI. + async fn release(&self, uri: &Url) -> Result<()>; +} + +/// A chain of storage backends that dispatches disk URI resolution to the backend +/// whose scheme matches the URI scheme. +/// +/// Disk paths that are not URIs or whose scheme has no registered backend are left unchanged. +pub struct StorageChain { + backends: Vec>, +} + +impl StorageChain { + pub fn new() -> Self { + Self { backends: vec![] } + } + + pub fn add(mut self, backend: B) -> Self { + self.backends.push(Box::new(backend)); + self + } +} + +impl Default for StorageChain { + fn default() -> Self { + Self::new().add(file::FileStorage).add(rbd::RbdStorage) + } +} + +impl ConfigTransform for StorageChain { + fn transform(&self, _vmid: &str, config: &mut VmConfig) -> Result<()> { + let Some(disks) = config.disks.as_mut() else { + return Ok(()); + }; + + for disk in disks { + let Some(ref path) = disk.path.clone() else { + continue; + }; + + let Ok(uri) = Url::parse(path) else { + continue; + }; + + let Some(backend) = self.backends.iter().find(|b| b.scheme() == uri.scheme()) else { + warn!( + scheme = uri.scheme(), + path, + "No storage backend registered for URI scheme, leaving disk path unchanged" + ); + continue; + }; + + let new_disk_id = uri + .clone() + .query_pairs_mut() + .append_pair("id", &disk.id.clone().unwrap_or_else(|| "".into())) + .finish() + .to_string(); + + disk.id = Some(new_disk_id); + + let resolved = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(backend.resolve(&uri)) + })?; + + disk.path = Some(resolved.to_string_lossy().into_owned()); + } + + Ok(()) + } +} diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo-agent/src/state/transform/storage/rbd.rs new file mode 100644 index 0000000..42d4248 --- /dev/null +++ b/odorobo-agent/src/state/transform/storage/rbd.rs @@ -0,0 +1,109 @@ +use super::StorageBackend; +use async_trait::async_trait; +use stable_eyre::{Result, eyre::eyre}; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use url::Url; + +const CEPH_ID_ENV: &str = "CEPH_ID"; +const CEPH_KEYFILE_ENV: &str = "CEPH_KEYFILE"; +const CEPH_CLUSTER_ENV: &str = "CEPH_CLUSTER"; +const CEPH_CONFIG_ENV: &str = "CEPH_CONFIG"; + +pub struct RbdStorage; + +fn rbd_extra_args() -> Vec { + let mut args = Vec::new(); + if let Ok(ceph_config) = std::env::var(CEPH_CONFIG_ENV) { + args.push(format!("--conf={ceph_config}")); + } + if let Ok(ceph_id) = std::env::var(CEPH_ID_ENV) { + args.push(format!("--id={ceph_id}")); + } + if let Ok(ceph_key) = std::env::var(CEPH_KEYFILE_ENV) { + args.push(format!("--keyfile={ceph_key}")); + } + if let Ok(ceph_cluster) = std::env::var(CEPH_CLUSTER_ENV) { + args.push(format!("--cluster={ceph_cluster}")); + } + args +} + +fn resolve_rbd_path(uri: &Url) -> Result { + let host = uri + .host_str() + .ok_or_else(|| eyre!("RBD URI must have a host (pool name)"))?; + let path = uri.path(); + if path.is_empty() || path == "/" { + return Err(eyre!("RBD URI must have a path (image name)")); + } + Ok(format!("{}{}", host, path)) +} + +#[async_trait] +impl StorageBackend for RbdStorage { + fn scheme(&self) -> &'static str { + "rbd" + } + + async fn resolve(&self, uri: &Url) -> Result { + // format is rbd://pool/image, we need to map this to a local block device using `rbd device map` + + let rbd_path = resolve_rbd_path(uri)?; + + // Ensure required environment variables are set + let output = Command::new("rbd") + .args(rbd_extra_args()) + .arg("device") + .arg("map") + .arg(&rbd_path) + .output() + .await + .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("rbd command failed: {stderr}")); + } + + // now we want to symlink this to a stable path under /run/odorobo/disks/rbd/{pool}/{image} so that we can use that path in the VM config and it won't change across reboots or remaps + let device_path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); + + let stable_path_str = format!("/run/odorobo/disks/rbd/{}", rbd_path); + let stable_path = Path::new(&stable_path_str); + + tokio::fs::create_dir_all(stable_path.parent().unwrap()).await?; + if std::path::Path::new(&stable_path).exists() { + tokio::fs::remove_file(&stable_path).await?; + } + tokio::fs::symlink(&device_path, &stable_path).await?; + + Ok(PathBuf::from(stable_path)) + } + + async fn release(&self, uri: &Url) -> Result<()> { + let rbd_path = resolve_rbd_path(uri)?; + let stable_path_str = format!("/run/odorobo/disks/rbd/{}", rbd_path); + let output = Command::new("rbd") + .args(rbd_extra_args()) + .arg("device") + .arg("unmap") + .arg(&rbd_path) + .output() + .await + .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("rbd unmap failed: {stderr}")); + } + + tokio::fs::remove_file(&stable_path_str).await?; + Ok(()) + } +} + +#[test] +fn test_resolve_rbd_path() { + let uri = Url::parse("rbd://my-pool/my-image").unwrap(); + let resolved = resolve_rbd_path(&uri).unwrap(); + assert_eq!(resolved, "my-pool/my-image"); +} From 7aec330da09f916a96e6ed7012ee380e5a79b81f Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 21:28:29 +0700 Subject: [PATCH 040/189] clean up RBD backend --- .../src/state/transform/storage/rbd.rs | 106 +++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo-agent/src/state/transform/storage/rbd.rs index 42d4248..6f78f23 100644 --- a/odorobo-agent/src/state/transform/storage/rbd.rs +++ b/odorobo-agent/src/state/transform/storage/rbd.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use stable_eyre::{Result, eyre::eyre}; use std::path::{Path, PathBuf}; use tokio::process::Command; +use tracing::{info, trace}; use url::Url; const CEPH_ID_ENV: &str = "CEPH_ID"; @@ -28,6 +29,85 @@ fn rbd_extra_args() -> Vec { } args } +#[tracing::instrument] +async fn rbd_map_list() -> Result> { + // returns a list of (rbd_path, device_path) for all currently mapped rbd devices + let output = Command::new("rbd") + .args(rbd_extra_args()) + .arg("device") + .arg("list") + .output() + .await + .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("rbd command failed: {stderr}")); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + rbd_lines_list(&output_str) +} + +#[tracing::instrument] +fn rbd_lines_list(input: &str) -> Result> { + let mut mappings = Vec::new(); + for line in input.lines().skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + // id pool namespace image snap device + // 0 pool foo testimg - /dev/rbd0 + if parts.len() == 6 { + let rbd_path = format!("{}/{}", parts[1], parts[3]); + let device_path = parts[5].to_string(); + mappings.push((rbd_path, device_path)); + } + if parts.len() == 5 { + // if namespace is empty, it might be omitted from the output, so we need to handle that case as well + // id pool image snap device + // 0 pool testimg - /dev/rbd0 + let rbd_path = format!("{}/{}", parts[1], parts[2]); + let device_path = parts[4].to_string(); + mappings.push((rbd_path, device_path)); + } + } + + trace!(?mappings, "Parsed RBD device list"); + Ok(mappings) +} + +/// Maps the given RBD image to a local block device and returns the device path. +/// If already mapped, returns the existing device path. +#[tracing::instrument] +async fn map_device(rbd_path: &str) -> Result { + let mappings = rbd_map_list().await?; + + if let Some((_, device_path)) = mappings.into_iter().find(|(path, _)| path == rbd_path) { + info!( + ?device_path, + "RBD image already mapped to device, returning existing mapping" + ); + Ok(device_path) + } else { + info!(?rbd_path, "Mapping RBD image to device"); + let output = Command::new("rbd") + .args(rbd_extra_args()) + .arg("device") + .arg("map") + .arg(rbd_path) + .output() + .await + .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("rbd command failed: {stderr}")); + } + + let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + info!(?output_str, "RBD image mapped to device"); + + Ok(output_str) + } +} fn resolve_rbd_path(uri: &Url) -> Result { let host = uri @@ -52,21 +132,10 @@ impl StorageBackend for RbdStorage { let rbd_path = resolve_rbd_path(uri)?; // Ensure required environment variables are set - let output = Command::new("rbd") - .args(rbd_extra_args()) - .arg("device") - .arg("map") - .arg(&rbd_path) - .output() - .await - .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(eyre!("rbd command failed: {stderr}")); - } + let output = map_device(&rbd_path).await?; // now we want to symlink this to a stable path under /run/odorobo/disks/rbd/{pool}/{image} so that we can use that path in the VM config and it won't change across reboots or remaps - let device_path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); + let device_path = PathBuf::from(output); let stable_path_str = format!("/run/odorobo/disks/rbd/{}", rbd_path); let stable_path = Path::new(&stable_path_str); @@ -107,3 +176,14 @@ fn test_resolve_rbd_path() { let resolved = resolve_rbd_path(&uri).unwrap(); assert_eq!(resolved, "my-pool/my-image"); } + +#[test] +fn test_rbd_lines_list() { + let input = "\ +id pool namespace image snap device +0 kessoku-blockpool testimg - /dev/rbd0"; + let mappings = rbd_lines_list(input).unwrap(); + assert_eq!(mappings.len(), 1); + assert_eq!(mappings[0].0, "kessoku-blockpool/testimg"); + assert_eq!(mappings[0].1, "/dev/rbd0"); +} From 0650d30ed71f57d2d529e35bfa54520edaa376eb Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 23:11:27 +0700 Subject: [PATCH 041/189] feat: iSCSI storage --- .../src/state/transform/storage/iscsi.rs | 248 ++++++++++++++++++ .../src/state/transform/storage/mod.rs | 6 +- 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 odorobo-agent/src/state/transform/storage/iscsi.rs diff --git a/odorobo-agent/src/state/transform/storage/iscsi.rs b/odorobo-agent/src/state/transform/storage/iscsi.rs new file mode 100644 index 0000000..50f2781 --- /dev/null +++ b/odorobo-agent/src/state/transform/storage/iscsi.rs @@ -0,0 +1,248 @@ +//! iSCSI initiator transformer for storage backend +//! resolves iscsi:// URIs by logging into the iSCSI target and returning the local +//! block device path for the specified target and LUN, e.g. /dev/disk/by-path/ip-* +use super::StorageBackend; +use async_trait::async_trait; +use serde::Deserialize; +use stable_eyre::{Result, eyre::eyre}; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use tracing::{info, trace}; +use url::Url; + +/// Struct representation of an iSCSI target, +/// parsed from the URI, e.g. +/// `iscsi://target.example.com:3260/iqn.2024-01.com.example:target1/0` would be parsed into +#[derive(Debug, Clone, Deserialize)] +pub struct ISCSITarget { + pub host: String, + pub iqn: String, + pub lun: u32, +} + +impl ISCSITarget { + pub fn to_device_path(&self) -> PathBuf { + PathBuf::from(format!( + "/dev/disk/by-path/ip-{}-iscsi-{}-lun-{}", + self.host, self.iqn, self.lun + )) + } + + #[tracing::instrument(skip(self))] + pub async fn attach(&self) -> Result { + // do iscsiadm login to the target, then find the corresponding device path in /dev/disk/by-path + info!(?self, "Attaching iSCSI target"); + + Command::new("iscsiadm") + .args(["-m", "node", "-T", &self.iqn, "-p", &self.host, "--login"]) + .output() + .await + .map_err(|e| eyre!("Failed to execute iscsiadm command: {e}"))?; + Ok(self.to_device_path()) + } + + #[tracing::instrument(skip(self))] + pub async fn detach(&self) -> Result<()> { + info!(?self, "Detaching iSCSI target"); + Command::new("iscsiadm") + .args(["-m", "node", "-T", &self.iqn, "-p", &self.host, "--logout"]) + .output() + .await + .map_err(|e| eyre!("Failed to execute iscsiadm command: {e}"))?; + Ok(()) + } +} + +impl Into for ISCSITarget { + fn into(self) -> PathBuf { + self.to_device_path() + } +} + +impl From<&Url> for ISCSITarget { + fn from(uri: &Url) -> Self { + let host_ip = uri.host_str().unwrap_or_default().to_string(); + let port = uri.port().unwrap_or(3260); + let host = format!("{}:{}", host_ip, port); + let path_segments: Vec<&str> = uri.path_segments().map(|c| c.collect()).unwrap_or_default(); + let iqn = path_segments.get(0).unwrap_or(&"").to_string(); + let lun_str = path_segments.get(1).unwrap_or(&""); + let lun = lun_str + .strip_prefix("lun") + .unwrap_or(lun_str) + .parse::() + .unwrap_or(0); + ISCSITarget { host, iqn, lun } + } +} + +fn list_iscsi_devices() -> Result> { + // simply just list /dev/disk/by-path/ip-* for now + + let mut devices = Vec::new(); + let by_path = Path::new("/dev/disk/by-path"); + if by_path.exists() { + for entry in by_path.read_dir()? { + let entry = entry?; + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + if file_name_str.starts_with("ip-") { + let device_path = entry.path(); + if device_path.exists() { + devices.push(device_path); + } + } + } + } + Ok(devices) +} + +// /dev/disk/by-path/ip-127.0.0.1:3260-iscsi-iqn.2026-03.com.fyrastack:test-lun-0 + +impl TryFrom for ISCSITarget { + type Error = stable_eyre::Report; + + fn try_from(path: PathBuf) -> Result { + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + // parse the filename to extract the host, iqn, and lun + + // strip the "ip-" prefix and split by "-iscsi-" + let stripped = file_name + .strip_prefix("ip-") + .ok_or_else(|| eyre!("Device path does not start with 'ip-': {}", path.display()))?; + + let (host_part, rest) = stripped.split_once("-iscsi-").ok_or_else(|| { + eyre!( + "Failed to parse iSCSI target from device path: {}", + path.display() + ) + })?; + + let (iqn_part, lun_part) = rest.rsplit_once("-lun-").unwrap_or((rest, "0")); + let host = host_part.to_string(); + let iqn = iqn_part.to_string(); + let lun = lun_part.parse::().unwrap_or(0); + Ok(ISCSITarget { host, iqn, lun }) + } +} + +pub struct ISCSIStorage; + +#[async_trait] +impl StorageBackend for ISCSIStorage { + fn scheme(&self) -> &'static str { + "iscsi" + } + async fn resolve(&self, uri: &Url) -> Result { + let target = ISCSITarget::from(uri); + target.attach().await + } + + async fn release(&self, uri: &Url) -> Result<()> { + let target = ISCSITarget::from(uri); + target.detach().await + } +} + +#[test] +fn test_iscsi_target_parsing_from_path() { + let path = PathBuf::from( + "/dev/disk/by-path/ip-127.0.0.1:3260-iscsi-iqn.2026-03.com.fyrastack:test-lun-0", + ); + let target = ISCSITarget::try_from(path).unwrap(); + println!("{:?}", target); + + assert_eq!(target.host, "127.0.0.1:3260"); + assert_eq!(target.iqn, "iqn.2026-03.com.fyrastack:test"); + assert_eq!(target.lun, 0); +} + +#[test] +fn test_iscsi_target_parsing() { + let uri = + Url::parse("iscsi://target.example.com:3260/iqn.2024-01.com.example:target1/lun0").unwrap(); + let target = ISCSITarget::from(&uri); + println!("{:?}", target); + assert_eq!(target.host, "target.example.com:3260"); + assert_eq!(target.iqn, "iqn.2024-01.com.example:target1"); + assert_eq!(target.lun, 0); + let uri2 = Url::parse("iscsi://target.example.com/iqn.2024-01.com.example:target1/0").unwrap(); + let target2 = ISCSITarget::from(&uri2); + println!("{:?}", target2); + assert_eq!(target2.host, "target.example.com:3260"); + assert_eq!(target2.iqn, "iqn.2024-01.com.example:target1"); + assert_eq!(target2.lun, 0); +} + +#[test] +fn test_iscsi_lossless_conversion() { + let uri = + Url::parse("iscsi://target.example.com:3260/iqn.2024-01.com.example:target1/0").unwrap(); + let target = ISCSITarget::from(&uri); + let reconstructed_uri = format!("iscsi://{}/{}/{}", target.host, target.iqn, target.lun); + assert_eq!(uri.as_str(), reconstructed_uri); +} +#[test] +fn test_iscsi_uri_to_device_path() { + let uri = + Url::parse("iscsi://target.example.com:3260/iqn.2026-03.com.fyrastack:test/0").unwrap(); + let actual_device_path = + "/dev/disk/by-path/ip-target.example.com:3260-iscsi-iqn.2026-03.com.fyrastack:test-lun-0"; + let target = ISCSITarget::from(&uri); + let device_path = target.to_device_path().display().to_string(); + println!("Device path: {}", device_path); + assert_eq!(device_path, actual_device_path); +} + +#[test] +fn test_iscsi_iqn_with_dashes_from_path() { + // IQN containing dashes (e.g. multi-word image names like "test-disk") + let path = PathBuf::from( + "/dev/disk/by-path/ip-127.0.0.1:3260-iscsi-iqn.2026-03.com.fyrastack:test-disk-lun-0", + ); + let target = ISCSITarget::try_from(path).unwrap(); + assert_eq!(target.host, "127.0.0.1:3260"); + assert_eq!(target.iqn, "iqn.2026-03.com.fyrastack:test-disk"); + assert_eq!(target.lun, 0); +} + +#[test] +fn test_iscsi_nonzero_lun_from_path() { + // LUN > 0 must not be silently truncated to 0 + let path = PathBuf::from( + "/dev/disk/by-path/ip-192.168.1.1:3260-iscsi-iqn.2026-03.com.fyrastack:boot-lun-2", + ); + let target = ISCSITarget::try_from(path).unwrap(); + assert_eq!(target.host, "192.168.1.1:3260"); + assert_eq!(target.iqn, "iqn.2026-03.com.fyrastack:boot"); + assert_eq!(target.lun, 2); +} + +#[test] +fn test_iscsi_nonzero_lun_from_uri() { + // both numeric (/1) and prefixed (/lun1) URI forms for LUN > 0 + let uri_numeric = + Url::parse("iscsi://target.example.com:3260/iqn.2026-03.com.fyrastack:boot/1").unwrap(); + let t1 = ISCSITarget::from(&uri_numeric); + assert_eq!(t1.lun, 1); + + let uri_prefixed = + Url::parse("iscsi://target.example.com:3260/iqn.2026-03.com.fyrastack:boot/lun1").unwrap(); + let t2 = ISCSITarget::from(&uri_prefixed); + assert_eq!(t2.lun, 1); +} + +#[test] +fn test_iscsi_device_path_roundtrip() { + // device_path → TryFrom → same ISCSITarget fields + let original = ISCSITarget { + host: "10.0.0.1:3260".to_string(), + iqn: "iqn.2026-03.com.fyrastack:test-disk".to_string(), + lun: 3, + }; + let device_path = original.to_device_path(); + let parsed = ISCSITarget::try_from(PathBuf::from(&device_path)).unwrap(); + assert_eq!(parsed.host, original.host); + assert_eq!(parsed.iqn, original.iqn); + assert_eq!(parsed.lun, original.lun); +} diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo-agent/src/state/transform/storage/mod.rs index 69f5e1b..7d0df2a 100644 --- a/odorobo-agent/src/state/transform/storage/mod.rs +++ b/odorobo-agent/src/state/transform/storage/mod.rs @@ -7,6 +7,7 @@ use tracing::warn; use url::Url; mod file; +mod iscsi; mod rbd; /// A storage backend for Odorobo to resolve storage URIs to local paths. @@ -60,7 +61,10 @@ impl StorageChain { impl Default for StorageChain { fn default() -> Self { - Self::new().add(file::FileStorage).add(rbd::RbdStorage) + Self::new() + .add(file::FileStorage) + .add(rbd::RbdStorage) + .add(iscsi::ISCSIStorage) } } From 893db02406a97c461acda8d5cd56d5e5f1d1ccc1 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 23:41:10 +0700 Subject: [PATCH 042/189] feat: teardown hooks, document storage transformer API --- docs/storage.md | 41 ++++++++++++++++++++++++ odorobo-agent/src/state/instance.rs | 12 ++++++- odorobo-agent/src/state/transform/mod.rs | 33 +++++++++++++++---- 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 docs/storage.md diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..6c1bdb5 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,41 @@ +# Storage Integration + +Orodobo adds a storage integration layer on top of Cloud Hypervisor's block device support, using config transformers to convert custom URI schemes into valid block device paths for Cloud Hypervisor. +This allows users to specify storage devices in a more flexible way, and enables simpler integration with networked storage backends, by having Odorobo abstract and attach/detach storage devices on demand based on the VM's lifecycle and provisioning hooks. + +Currently, Odorobo's Storage transformer supports the following backends: + +- **Local Disk Images**: Specify a local disk image with the `file://` URI scheme, e.g. `file:///var/lib/odorobo/images/my-disk.qcow2`. This is simply passed through to Cloud Hypervisor as if the `file://` prefix was not there, implemented as a dummy transformer for validating the storage transformer API. +- **Ceph RBD**: Specify a Ceph RBD image with the `rbd://` URI scheme, e.g. `rbd://my-pool/my-image`. Odorobo will use the `rbd` command line tool to map the RBD image to a local block device (e.g. `/dev/rbd0`), and then pass this block device path to Cloud Hypervisor. +- **iSCSI**: Specify an iSCSI target with the `iscsi://` URI scheme, e.g. `iscsi://://`. Odorobo will use the `iscsiadm` command line tool to log in to the iSCSI target and map the specified LUN to a local block device (e.g. `/dev/disk/by-path/ip-:-iscsi--lun-`), and then pass this block device path to Cloud Hypervisor. + +To use the storage transformers, simply specify the desired URI in place of the `path` field for a block device in your VM config. For example: + +```json +{ + ... + "disks": [ + { + "path": "rdb://my-pool/my-image", + "id": "disk0", + } + ], +} +``` + +Odorobo will automatically detect the `rbd://` scheme, map the RBD image to a local block device, and pass the block device path to Cloud Hypervisor when creating the VM, transforming them to: + +```json +{ + ... + "disks": [ + { + "path": "/dev/rbd0", + "id": "rdb://my-pool/my-image?id=disk0", + } + ], +} +``` + +The `id` field is transformed to include the original URI for reference, and can be used in provisioning hooks to identify which disk is which when attaching/detaching storage devices on demand. + diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 066c2fb..6c28402 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -18,6 +18,8 @@ use std::{ use thiserror::Error; use tracing::{debug, error, info, trace, warn}; +use crate::state::transform::{ConfigTransform, TransformChain}; + use super::api::{call, call_request}; use super::transform::apply_builtin_transforms; @@ -128,7 +130,13 @@ impl VMInstance { .wrap_err(eyre!( "Failed to send migration command for {}", self.vm_id() - )) + ))?; + + // if migration is successful, we can assume the VM is effectively "gone" from this host, so we can clean up runtime state. + self.purge_instance_data() + .wrap_err("Failed to purge instance data after migration")?; + + Ok(()) } /// Prepares the VM to receive a migration by starting a migration receiver in the background. @@ -381,6 +389,8 @@ impl VMInstance { /// /// This removes the runtime directory and all its contents if it exists. pub fn purge_instance_data(&self) -> Result<()> { + let mut vm_config = self.load_config().unwrap_or_default(); + TransformChain::default().teardown(self.vm_id(), &mut vm_config)?; let runtime_dir = self.runtime_dir(); if runtime_dir.exists() { fs::remove_dir_all(runtime_dir).wrap_err(eyre!( diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 81a094f..5f2c4bc 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -3,6 +3,12 @@ use stable_eyre::Result; pub trait ConfigTransform: Send + Sync { fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()>; + + /// Optional teardown method to reverse transformations if needed, + /// used for tearing down VMs + fn teardown(&self, _vmid: &str, _config: &mut VmConfig) -> Result<()> { + Ok(()) + } } mod console; @@ -10,8 +16,8 @@ mod path_verify; mod storage; pub use console::ConsoleTransform; pub use path_verify::PathVerify; +use tracing::trace; -#[derive(Default)] pub struct TransformChain(Vec>); impl TransformChain { @@ -31,18 +37,31 @@ impl TransformChain { impl ConfigTransform for TransformChain { fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()> { + trace!("Applying transform chain with {} transforms", self.0.len()); for t in &self.0 { t.transform(vmid, config)?; } Ok(()) } + + fn teardown(&self, vmid: &str, config: &mut VmConfig) -> Result<()> { + trace!("Teardown transform chain with {} transforms", self.0.len()); + for t in self.0.iter().rev() { + t.teardown(vmid, config)?; + } + Ok(()) + } +} + +impl Default for TransformChain { + fn default() -> Self { + Self::new() + .add(ConsoleTransform) + .add(PathVerify) + .add(storage::StorageChain::default()) + } } pub fn apply_builtin_transforms(vmid: &str, config: &mut VmConfig) -> Result<()> { - TransformChain::new() - .add(ConsoleTransform) - .add(PathVerify) - .add(storage::StorageChain::default()) - .then() - .transform(vmid, config) + TransformChain::default().transform(vmid, config) } From 6f168bda1d0d801a0575b2b617b3faeccbc5264c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 4 Apr 2026 23:58:00 +0700 Subject: [PATCH 043/189] rbd: remove symlink hack Greatly simplifies RBD attachment by removing odorobo's symlink creation, assuming the relevant symlink is automatically created by Ceph's udev rules in `/dev/rbd` Depends on the following udev rule, usually included in `ceph-common` package ``` KERNEL=="rbd[0-9]*", ENV{DEVTYPE}=="disk", PROGRAM="/usr/bin/ceph-rbdnamer %k", SYMLINK+="rbd/%c" KERNEL=="rbd[0-9]*", ENV{DEVTYPE}=="partition", PROGRAM="/usr/bin/ceph-rbdnamer %k", SYMLINK+="rbd/%c-part%n" ``` --- docs/storage.md | 13 ++++++++- .../src/state/transform/storage/rbd.rs | 27 +++---------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/docs/storage.md b/docs/storage.md index 6c1bdb5..9305377 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -30,7 +30,7 @@ Odorobo will automatically detect the `rbd://` scheme, map the RBD image to a lo ... "disks": [ { - "path": "/dev/rbd0", + "path": "/dev/rbd/my-pool/my-image", "id": "rdb://my-pool/my-image?id=disk0", } ], @@ -39,3 +39,14 @@ Odorobo will automatically detect the `rbd://` scheme, map the RBD image to a lo The `id` field is transformed to include the original URI for reference, and can be used in provisioning hooks to identify which disk is which when attaching/detaching storage devices on demand. + +> [!NOTE] +> Ceph RBD integration requires the following udev rule to allow Odorobo to easily find the mapped block device for a given RBD image: +> +> ``` +> # /etc/udev/rules.d/50-rbd.rules +> KERNEL=="rbd[0-9]*", ENV{DEVTYPE}=="disk", PROGRAM="/usr/bin/ceph-rbdnamer %k", SYMLINK+="rbd/%c" +> KERNEL=="rbd[0-9]*", ENV{DEVTYPE}=="partition", PROGRAM="/usr/bin/ceph-rbdnamer %k", SYMLINK+="rbd/%c-part%n" +> ``` +> +> This rule is commonly included in Ceph packages (`ceph-common` in Fedora), but if your distribution does not include it by default, you will need to add it manually for RBD support to work properly. This allows Odorobo to find the mapped block device for a given RBD image under `/dev/rbd/`, which is necessary for passing the correct block device path to Cloud Hypervisor. diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo-agent/src/state/transform/storage/rbd.rs index 6f78f23..1a07735 100644 --- a/odorobo-agent/src/state/transform/storage/rbd.rs +++ b/odorobo-agent/src/state/transform/storage/rbd.rs @@ -1,7 +1,7 @@ use super::StorageBackend; use async_trait::async_trait; use stable_eyre::{Result, eyre::eyre}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokio::process::Command; use tracing::{info, trace}; use url::Url; @@ -127,31 +127,14 @@ impl StorageBackend for RbdStorage { } async fn resolve(&self, uri: &Url) -> Result { - // format is rbd://pool/image, we need to map this to a local block device using `rbd device map` - + // format is rbd://pool/image; map and return the udev-stable path at /dev/rbd// let rbd_path = resolve_rbd_path(uri)?; - - // Ensure required environment variables are set - let output = map_device(&rbd_path).await?; - - // now we want to symlink this to a stable path under /run/odorobo/disks/rbd/{pool}/{image} so that we can use that path in the VM config and it won't change across reboots or remaps - let device_path = PathBuf::from(output); - - let stable_path_str = format!("/run/odorobo/disks/rbd/{}", rbd_path); - let stable_path = Path::new(&stable_path_str); - - tokio::fs::create_dir_all(stable_path.parent().unwrap()).await?; - if std::path::Path::new(&stable_path).exists() { - tokio::fs::remove_file(&stable_path).await?; - } - tokio::fs::symlink(&device_path, &stable_path).await?; - - Ok(PathBuf::from(stable_path)) + map_device(&rbd_path).await?; + Ok(PathBuf::from(format!("/dev/rbd/{}", rbd_path))) } async fn release(&self, uri: &Url) -> Result<()> { let rbd_path = resolve_rbd_path(uri)?; - let stable_path_str = format!("/run/odorobo/disks/rbd/{}", rbd_path); let output = Command::new("rbd") .args(rbd_extra_args()) .arg("device") @@ -164,8 +147,6 @@ impl StorageBackend for RbdStorage { let stderr = String::from_utf8_lossy(&output.stderr); return Err(eyre!("rbd unmap failed: {stderr}")); } - - tokio::fs::remove_file(&stable_path_str).await?; Ok(()) } } From 10852c7ff3bacfbd2d0949eceb18a81ebebff4c5 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 5 Apr 2026 00:40:11 +0700 Subject: [PATCH 044/189] Actually wire up teardown hooks --- .../src/state/provisioning/hooks/mod.rs | 1 - odorobo-agent/src/state/transform/mod.rs | 4 +- .../src/state/transform/storage/file.rs | 21 ++- .../src/state/transform/storage/mod.rs | 39 +++++- .../src/state/transform/storage/rbd.rs | 131 +++++++++++------- systemd/odorobo-ch@.service | 36 ----- 6 files changed, 137 insertions(+), 95 deletions(-) diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index 29778cf..de224c8 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -13,7 +13,6 @@ use stable_eyre::Result; mod machined; - // Rust 1.75 does not support dyn async traits, we still need async_trait for this #[async_trait] pub trait ProvisioningHook: Send + Sync { diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 5f2c4bc..d6fb15e 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -13,7 +13,7 @@ pub trait ConfigTransform: Send + Sync { mod console; mod path_verify; -mod storage; +pub mod storage; pub use console::ConsoleTransform; pub use path_verify::PathVerify; use tracing::trace; @@ -56,9 +56,9 @@ impl ConfigTransform for TransformChain { impl Default for TransformChain { fn default() -> Self { Self::new() + .add(storage::StorageChain::default()) .add(ConsoleTransform) .add(PathVerify) - .add(storage::StorageChain::default()) } } diff --git a/odorobo-agent/src/state/transform/storage/file.rs b/odorobo-agent/src/state/transform/storage/file.rs index 78f84be..f233be9 100644 --- a/odorobo-agent/src/state/transform/storage/file.rs +++ b/odorobo-agent/src/state/transform/storage/file.rs @@ -9,7 +9,23 @@ use stable_eyre::{Result, eyre::eyre}; use std::path::PathBuf; use url::Url; +pub struct FileTarget { + pub path: PathBuf, +} + +impl TryFrom<&Url> for FileTarget { + type Error = stable_eyre::Report; + + fn try_from(uri: &Url) -> Result { + let path = uri + .to_file_path() + .map_err(|_| eyre!("Failed to convert file URI to path: '{}'", uri.as_str()))?; + Ok(FileTarget { path }) + } +} + pub struct FileStorage; + #[async_trait] impl StorageBackend for FileStorage { fn scheme(&self) -> &'static str { @@ -17,10 +33,7 @@ impl StorageBackend for FileStorage { } async fn resolve(&self, uri: &Url) -> Result { - let path = uri - .to_file_path() - .map_err(|_| eyre!("Failed to convert file URI to path: '{}'", uri.as_str()))?; - Ok(path) + Ok(FileTarget::try_from(uri)?.path) } async fn release(&self, _uri: &Url) -> Result<()> { diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo-agent/src/state/transform/storage/mod.rs index 7d0df2a..3cdf9ca 100644 --- a/odorobo-agent/src/state/transform/storage/mod.rs +++ b/odorobo-agent/src/state/transform/storage/mod.rs @@ -57,6 +57,36 @@ impl StorageChain { self.backends.push(Box::new(backend)); self } + + fn find_backend(&self, uri: &Url) -> Option<&dyn StorageBackend> { + self.backends + .iter() + .find(|b| b.scheme() == uri.scheme()) + .map(|b| b.as_ref()) + } + + /// Releases storage resources for all URI-backed disks in a VM config. + /// Checks both `path` and `id` fields for a URI, since after transforms `path` is the + /// resolved device path while `id` holds the original URI. + /// Errors are logged as warnings and do not abort the remaining releases. + pub async fn release_config(&self, config: &VmConfig) { + let Some(disks) = config.disks.as_ref() else { + return; + }; + for disk in disks { + let candidates = [disk.path.as_deref(), disk.id.as_deref()]; + let Some((uri, backend)) = candidates.into_iter().flatten().find_map(|s| { + let uri = Url::parse(s).ok()?; + let backend = self.find_backend(&uri)?; + Some((uri, backend)) + }) else { + continue; + }; + if let Err(e) = backend.release(&uri).await { + warn!(path = uri.as_str(), error = ?e, "Failed to release storage for disk"); + } + } + } } impl Default for StorageChain { @@ -69,6 +99,13 @@ impl Default for StorageChain { } impl ConfigTransform for StorageChain { + fn teardown(&self, _vmid: &str, config: &mut VmConfig) -> Result<()> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.release_config(config)) + }); + Ok(()) + } + fn transform(&self, _vmid: &str, config: &mut VmConfig) -> Result<()> { let Some(disks) = config.disks.as_mut() else { return Ok(()); @@ -83,7 +120,7 @@ impl ConfigTransform for StorageChain { continue; }; - let Some(backend) = self.backends.iter().find(|b| b.scheme() == uri.scheme()) else { + let Some(backend) = self.find_backend(&uri) else { warn!( scheme = uri.scheme(), path, diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo-agent/src/state/transform/storage/rbd.rs index 1a07735..8653721 100644 --- a/odorobo-agent/src/state/transform/storage/rbd.rs +++ b/odorobo-agent/src/state/transform/storage/rbd.rs @@ -11,8 +11,6 @@ const CEPH_KEYFILE_ENV: &str = "CEPH_KEYFILE"; const CEPH_CLUSTER_ENV: &str = "CEPH_CLUSTER"; const CEPH_CONFIG_ENV: &str = "CEPH_CONFIG"; -pub struct RbdStorage; - fn rbd_extra_args() -> Vec { let mut args = Vec::new(); if let Ok(ceph_config) = std::env::var(CEPH_CONFIG_ENV) { @@ -29,6 +27,7 @@ fn rbd_extra_args() -> Vec { } args } + #[tracing::instrument] async fn rbd_map_list() -> Result> { // returns a list of (rbd_path, device_path) for all currently mapped rbd devices @@ -74,52 +73,89 @@ fn rbd_lines_list(input: &str) -> Result> { Ok(mappings) } -/// Maps the given RBD image to a local block device and returns the device path. -/// If already mapped, returns the existing device path. -#[tracing::instrument] -async fn map_device(rbd_path: &str) -> Result { - let mappings = rbd_map_list().await?; - - if let Some((_, device_path)) = mappings.into_iter().find(|(path, _)| path == rbd_path) { - info!( - ?device_path, - "RBD image already mapped to device, returning existing mapping" - ); - Ok(device_path) - } else { +#[derive(Debug, Clone)] +pub struct RbdImage { + pub pool: String, + pub image: String, +} + +impl RbdImage { + pub fn rbd_path(&self) -> String { + format!("{}/{}", self.pool, self.image) + } + + /// Returns the udev-stable device path at `/dev/rbd//`. + pub fn device_path(&self) -> PathBuf { + PathBuf::from(format!("/dev/rbd/{}/{}", self.pool, self.image)) + } + + /// Maps the RBD image to a kernel block device. If already mapped, this is a no-op. + #[tracing::instrument(skip(self))] + pub async fn map(&self) -> Result<()> { + let rbd_path = self.rbd_path(); + let mappings = rbd_map_list().await?; + + if mappings.iter().any(|(path, _)| path == &rbd_path) { + info!(?rbd_path, "RBD image already mapped, reusing"); + return Ok(()); + } + info!(?rbd_path, "Mapping RBD image to device"); let output = Command::new("rbd") .args(rbd_extra_args()) .arg("device") .arg("map") - .arg(rbd_path) + .arg(&rbd_path) .output() .await .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(eyre!("rbd command failed: {stderr}")); + return Err(eyre!("rbd map failed: {stderr}")); } + Ok(()) + } - let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - info!(?output_str, "RBD image mapped to device"); - - Ok(output_str) + /// Unmaps the RBD image from the kernel block device. + #[tracing::instrument(skip(self))] + pub async fn unmap(&self) -> Result<()> { + let rbd_path = self.rbd_path(); + info!(?rbd_path, "Unmapping RBD image"); + let output = Command::new("rbd") + .args(rbd_extra_args()) + .arg("device") + .arg("unmap") + .arg(&rbd_path) + .output() + .await + .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(eyre!("rbd unmap failed: {stderr}")); + } + Ok(()) } } -fn resolve_rbd_path(uri: &Url) -> Result { - let host = uri - .host_str() - .ok_or_else(|| eyre!("RBD URI must have a host (pool name)"))?; - let path = uri.path(); - if path.is_empty() || path == "/" { - return Err(eyre!("RBD URI must have a path (image name)")); +impl TryFrom<&Url> for RbdImage { + type Error = stable_eyre::Report; + + fn try_from(uri: &Url) -> Result { + let pool = uri + .host_str() + .ok_or_else(|| eyre!("RBD URI must have a host (pool name)"))? + .to_string(); + let path = uri.path(); + if path.is_empty() || path == "/" { + return Err(eyre!("RBD URI must have a path (image name)")); + } + let image = path.trim_start_matches('/').to_string(); + Ok(RbdImage { pool, image }) } - Ok(format!("{}{}", host, path)) } +pub struct RbdStorage; + #[async_trait] impl StorageBackend for RbdStorage { fn scheme(&self) -> &'static str { @@ -127,35 +163,28 @@ impl StorageBackend for RbdStorage { } async fn resolve(&self, uri: &Url) -> Result { - // format is rbd://pool/image; map and return the udev-stable path at /dev/rbd// - let rbd_path = resolve_rbd_path(uri)?; - map_device(&rbd_path).await?; - Ok(PathBuf::from(format!("/dev/rbd/{}", rbd_path))) + let image = RbdImage::try_from(uri)?; + image.map().await?; + Ok(image.device_path()) } async fn release(&self, uri: &Url) -> Result<()> { - let rbd_path = resolve_rbd_path(uri)?; - let output = Command::new("rbd") - .args(rbd_extra_args()) - .arg("device") - .arg("unmap") - .arg(&rbd_path) - .output() - .await - .map_err(|e| eyre!("Failed to execute rbd command: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(eyre!("rbd unmap failed: {stderr}")); - } - Ok(()) + let image = RbdImage::try_from(uri)?; + image.unmap().await } } #[test] -fn test_resolve_rbd_path() { +fn test_rbd_image_from_uri() { let uri = Url::parse("rbd://my-pool/my-image").unwrap(); - let resolved = resolve_rbd_path(&uri).unwrap(); - assert_eq!(resolved, "my-pool/my-image"); + let image = RbdImage::try_from(&uri).unwrap(); + assert_eq!(image.pool, "my-pool"); + assert_eq!(image.image, "my-image"); + assert_eq!(image.rbd_path(), "my-pool/my-image"); + assert_eq!( + image.device_path(), + std::path::PathBuf::from("/dev/rbd/my-pool/my-image") + ); } #[test] diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 5237947..02b52c1 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -33,41 +33,5 @@ RestartSec=3s RestartPreventExitStatus=0 TimeoutStopSec=30s -CPUAccounting=yes -MemoryAccounting=yes -IOAccounting=yes -TasksAccounting=yes - -# Security hardening -NoNewPrivileges=yes -# PrivateTmp=yes -# ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectControlGroups=yes -RestrictNamespaces=yes -LockPersonality=yes -# CH needs JIT/executable memory -MemoryDenyWriteExecute=no -# vCPU threads need realtime scheduling -RestrictRealtime=no -AmbientCapabilities=CAP_NET_ADMIN - -# Paths CH legitimately needs to write -# ReadWritePaths=/run/odorobo/vms/%i /var/log/odorobo /etc/odorobo /var/lib/odorobo - -# KVM device access -DeviceAllow=/dev/kvm rw -DeviceAllow=/dev/net/tun rw -DeviceAllow=/dev/vhost-net rw -DeviceAllow=/dev/urandom r - -# storage -DeviceAllow=/dev/nbd* rw -DeviceAllow=/dev/rbd* rw -DeviceAllow=/dev/sd* rw -DeviceAllow=/dev/nvme* rw -DeviceAllow=/dev/disk/by-id/* rw - [Install] WantedBy=multi-user.target From 37e9208e3f8a946b0101f5b431bea053ac85afee Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 5 Apr 2026 01:19:27 +0700 Subject: [PATCH 045/189] allow vmm to be shut down using systemd if unresponsive --- odorobo-agent/src/state/instance.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 6c28402..6e51cd2 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -354,6 +354,11 @@ impl VMInstance { info!(vm_id = self.vm_id(), "Shutting down VM before destroy"); self.shutdown().await?; } + } else { + warn!( + vm_id = self.vm_id(), + "Failed to get VM info before destroy, proceeding with shutdown and cleanup anyway" + ); } let provisioner = super::provisioning::default_provisioner(); @@ -366,11 +371,14 @@ impl VMInstance { .or_else(|| self.load_config().ok()) .unwrap_or_default(); - self.conn() - .shutdown_vmm() - .await - .map_err(ChApiError::from) - .wrap_err(eyre!("Failed to shutdown VMM for {}", self.vm_id()))?; + if let Ok(()) = self.conn().shutdown_vmm().await { + debug!(vm_id = self.vm_id(), "VMM shutdown successfully"); + } else { + warn!( + vm_id = self.vm_id(), + "Failed to shutdown VMM, assuming it is already stopped or unresponsive" + ); + } provisioner.stop_instance(self.vm_id(), &vm_config).await?; From feb8bfb0860e437d2a3e357ce768a94f7404d660 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 5 Apr 2026 01:27:42 +0700 Subject: [PATCH 046/189] don't use systemd runtime directories for cleanup --- systemd/odorobo-agent.service | 6 ------ systemd/odorobo-ch@.service | 2 -- systemd/scripts/odorobo-preflight | 3 +++ 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/systemd/odorobo-agent.service b/systemd/odorobo-agent.service index d659de7..be768ea 100644 --- a/systemd/odorobo-agent.service +++ b/systemd/odorobo-agent.service @@ -10,12 +10,6 @@ Type=simple User=root Group=root -RuntimeDirectory=odorobo/ -RuntimeDirectoryMode=1775 -LogsDirectory=odorobo -LogsDirectoryMode=0750 -ConfigurationDirectory=odorobo/ -ConfigurationDirectoryMode=0750 ExecStart=/usr/bin/odorobo-agent diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service index 02b52c1..7436bec 100644 --- a/systemd/odorobo-ch@.service +++ b/systemd/odorobo-ch@.service @@ -13,8 +13,6 @@ Type=simple User=root Group=root -RuntimeDirectory=odorobo/vms/%i -RuntimeDirectoryMode=0770 LogsDirectory=odorobo LogsDirectoryMode=0750 ConfigurationDirectory=odorobo/vms/%i diff --git a/systemd/scripts/odorobo-preflight b/systemd/scripts/odorobo-preflight index bda8065..69725c8 100644 --- a/systemd/scripts/odorobo-preflight +++ b/systemd/scripts/odorobo-preflight @@ -5,3 +5,6 @@ if [ -z "$VMID" ]; then echo "Usage: $0 " exit 1 fi + +mkdir -p /run/odorobo/vms/$VMID +chmod -R 775 /run/odorobo/vms/$VMID \ No newline at end of file From b7726082e50d2bd822ff5e138eece2e116d2a6ff Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sun, 5 Apr 2026 02:34:29 +0700 Subject: [PATCH 047/189] Update HACKING.md --- HACKING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/HACKING.md b/HACKING.md index ebcda4e..36a86c9 100644 --- a/HACKING.md +++ b/HACKING.md @@ -52,3 +52,7 @@ of dynamic dispatch for config hooks and config transformers, which are still no To work around this, Odorobo uses the `async_trait` crate to allow async functions in traits with dynamic dispatch. If your trait needs to do dynamic dispatch (e.g. for provisioning hooks), you must use `async_trait`. Else use normal async functions. + +## Rust Hypervisor Firmware failing to boot newer images + +out of scope for odorobo, tracking issue [here](https://github.com/cloud-hypervisor/rust-hypervisor-firmware/issues/412) \ No newline at end of file From 0f204d904764ff7dbb44b78d6ed4d91f4e7fda13 Mon Sep 17 00:00:00 2001 From: Amy Ringo Date: Sat, 4 Apr 2026 18:48:00 -0500 Subject: [PATCH 048/189] add support for cross-compiling for RVA23 --- .github/workflows/rva23.yml | 27 +++++++++++++++++++++++++++ Containerfile | 15 +++++++++++++++ scripts/zig-cc-rva23.sh | 17 +++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 .github/workflows/rva23.yml create mode 100644 Containerfile create mode 100755 scripts/zig-cc-rva23.sh diff --git a/.github/workflows/rva23.yml b/.github/workflows/rva23.yml new file mode 100644 index 0000000..9e23456 --- /dev/null +++ b/.github/workflows/rva23.yml @@ -0,0 +1,27 @@ +name: Cross-compile to RVA23 + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: podman build -t odorobo-builder:rva23 . + - name: Build binaries + run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: odorobo + path: | + target/riscv64a23-unknown-linux-gnu/release/odorobo-agent + target/riscv64a23-unknown-linux-gnu/release/odoroboctl diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..8f6fe62 --- /dev/null +++ b/Containerfile @@ -0,0 +1,15 @@ +FROM alpine:3.23.3 +RUN apk add \ + bash \ + gcc \ + musl-dev \ + rustup \ + zig +RUN rustup-init \ + --profile minimal \ + --target riscv64a23-unknown-linux-gnu \ + -y +ENV CARGO_BUILD_TARGET="riscv64a23-unknown-linux-gnu" +ENV PATH="/root/.cargo/bin:$PATH" +ENV CC_riscv64a23_unknown_linux_gnu="/code/scripts/zig-cc-rva23.sh" +ENV CARGO_TARGET_RISCV64A23_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C linker=scripts/zig-cc-rva23.sh" diff --git a/scripts/zig-cc-rva23.sh b/scripts/zig-cc-rva23.sh new file mode 100755 index 0000000..fd0d8e6 --- /dev/null +++ b/scripts/zig-cc-rva23.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# A script for cargo to use as the C compiler and linker when building RVA23 +# binaries. This links them using zig cc. + +args=() +for arg in "$@"; do + case "$arg" in + --target=riscv64-unknown-linux-gnu) + ;; + *) + args+=("$arg") + ;; + esac +done +exec zig cc -target riscv64-linux-gnu "${args[@]}" From 7798c1cebbd5b2fef66b81e19b5d076c66feb453 Mon Sep 17 00:00:00 2001 From: Amy Ringo Date: Sat, 4 Apr 2026 18:53:53 -0500 Subject: [PATCH 049/189] add caching for intermediate files --- .github/workflows/rva23.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/rva23.yml b/.github/workflows/rva23.yml index 9e23456..6cd2761 100644 --- a/.github/workflows/rva23.yml +++ b/.github/workflows/rva23.yml @@ -14,6 +14,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Build image run: podman build -t odorobo-builder:rva23 . - name: Build binaries From 6f73d1d0b2fa54229e824fac619309a6421a595b Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 02:25:46 -0500 Subject: [PATCH 050/189] Add .idea to gitignore. This arguably shouldn't be in this PR, but I need it due to using RustRover. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 27e517e..cf82bef 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ dev # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # AI Slop # Anyone who uses LLMs for slop gets to keep their own slop context. From 308dc6e598f941b6fa76fa60d0b6f06740870a76 Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 02:27:14 -0500 Subject: [PATCH 051/189] make x86 and rva23 naming consistent and add upload artifact to x86 --- .github/workflows/{rva23.yml => build_riscv_rva23.yml} | 6 +++--- .github/workflows/{rust.yml => build_x86.yml} | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) rename .github/workflows/{rva23.yml => build_riscv_rva23.yml} (85%) rename .github/workflows/{rust.yml => build_x86.yml} (58%) diff --git a/.github/workflows/rva23.yml b/.github/workflows/build_riscv_rva23.yml similarity index 85% rename from .github/workflows/rva23.yml rename to .github/workflows/build_riscv_rva23.yml index 6cd2761..beca89c 100644 --- a/.github/workflows/rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -1,4 +1,4 @@ -name: Cross-compile to RVA23 +name: Build RISC V RVA23 via x86 cross-compile on: push: @@ -26,11 +26,11 @@ jobs: - name: Build image run: podman build -t odorobo-builder:rva23 . - name: Build binaries - run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release + run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: odorobo + name: odorobo_riscv_rva23 path: | target/riscv64a23-unknown-linux-gnu/release/odorobo-agent target/riscv64a23-unknown-linux-gnu/release/odoroboctl diff --git a/.github/workflows/rust.yml b/.github/workflows/build_x86.yml similarity index 58% rename from .github/workflows/rust.yml rename to .github/workflows/build_x86.yml index 9fd45e0..5958a0d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/build_x86.yml @@ -1,4 +1,4 @@ -name: Rust +name: Build x86 on: push: @@ -18,5 +18,12 @@ jobs: - uses: actions/checkout@v4 - name: Build run: cargo build --verbose + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: odorobo_x86 + path: | + target/release/odorobo-agent + target/release/odoroboctl - name: Run tests run: cargo test --verbose From c6331a112818d001cfc8eca1fdc6d5087e10c84a Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 02:35:04 -0500 Subject: [PATCH 052/189] figured out my mistake with x86. forgot to add --release. --- .github/workflows/build_x86.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 5958a0d..76aae89 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Build - run: cargo build --verbose + run: cargo build --verbose --release - name: Upload artifact uses: actions/upload-artifact@v4 with: From fce74140685a26aa7e9e5979893a470f55cc5ef5 Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 12:20:52 -0500 Subject: [PATCH 053/189] add connect to swarm function to shared crate. --- Cargo.lock | 1911 +++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- shared/Cargo.toml | 10 + shared/src/lib.rs | 89 ++ shared/src/utils.rs | 1 + 5 files changed, 1960 insertions(+), 53 deletions(-) create mode 100644 shared/Cargo.toml create mode 100644 shared/src/lib.rs create mode 100644 shared/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index d988835..7c5eaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,53 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -91,6 +138,51 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -222,12 +314,37 @@ dependencies = [ "syn", ] +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -378,6 +495,22 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str 0.4.3", + "match-lookup", +] + [[package]] name = "base64" version = "0.7.0" @@ -394,12 +527,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -422,6 +570,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -439,6 +596,18 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cbor4ii" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "472931dd4dfcc785075b09be910147f9c6258883fc4591d0dac6116392b2daa6" +dependencies = [ + "serde", +] [[package]] name = "cc" @@ -470,6 +639,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -484,6 +677,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -579,6 +783,44 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" +dependencies = [ + "const-str-proc-macro", +] + +[[package]] +name = "const-str-proc-macro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c25c2a02ba19f2d4fd9f54d5f239f97c867deb7397763a9771edab63c44a4fa" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -605,6 +847,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -614,12 +865,42 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -627,30 +908,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] [[package]] -name = "data-encoding" -version = "2.10.0" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] [[package]] -name = "digest" -version = "0.10.7" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "block-buffer", - "crypto-common", + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", @@ -658,58 +949,199 @@ dependencies = [ ] [[package]] -name = "dunce" -version = "1.0.5" +name = "data-encoding" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "data-encoding-macro" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ - "cfg-if", + "data-encoding", + "data-encoding-macro-internal", ] [[package]] -name = "endi" -version = "1.1.1" +name = "data-encoding-macro-internal" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn", +] [[package]] -name = "enumflags2" -version = "0.7.12" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "enumflags2_derive", - "serde", + "const-oid", + "zeroize", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "der-parser" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "proc-macro2", - "quote", - "syn", + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] [[package]] -name = "errno" -version = "0.3.14" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", @@ -752,6 +1184,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -800,6 +1238,16 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -857,6 +1305,17 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -869,6 +1328,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -936,6 +1401,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.28.1" @@ -961,6 +1436,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -976,6 +1460,24 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -994,6 +1496,77 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex_fmt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -1106,7 +1679,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -1271,6 +1844,60 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation 0.9.4", + "fnv", + "futures", + "if-addrs 0.15.0", + "ipnet", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration", + "tokio", + "windows", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "indenter" version = "0.3.4" @@ -1289,6 +1916,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1382,10 +2031,44 @@ dependencies = [ ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "kameo" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "9c4af7638c67029fd6821d02813c3913c803784648725d4df4082c9b91d7cbb1" +dependencies = [ + "const-fnv1a-hash", + "const-str 0.7.1", + "downcast-rs", + "dyn-clone", + "either", + "futures", + "kameo_macros", + "libp2p", + "libp2p-identity", + "linkme", + "rmp-serde", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "kameo_macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13c324e2d8c8e126e63e66087448b4267e263e6cb8770c56d10a9d0d279d9e2" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "leb128fmt" @@ -1399,6 +2082,397 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libp2p" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" +dependencies = [ + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dns", + "libp2p-gossipsub", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-noise", + "libp2p-quic", + "libp2p-request-response", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "libp2p-yamux", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", +] + +[[package]] +name = "libp2p-allow-block-list" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-connection-limits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot", + "pin-project", + "quick-protobuf", + "rand 0.8.5", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-gossipsub" +version = "0.49.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a538e571cd38f504f761c61b8f79127489ea7a7d6f05c41ca15d31ffb5726326" +dependencies = [ + "async-channel", + "asynchronous-codec", + "base64 0.22.1", + "byteorder", + "bytes", + "either", + "fnv", + "futures", + "futures-timer", + "getrandom 0.2.17", + "hashlink 0.9.1", + "hex_fmt", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.5", + "regex", + "serde", + "sha2", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-identity" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" +dependencies = [ + "bs58", + "ed25519-dalek", + "hkdf", + "multihash", + "quick-protobuf", + "rand 0.8.5", + "serde", + "sha2", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.5", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.5", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-kad", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-noise" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf" +dependencies = [ + "asynchronous-codec", + "bytes", + "futures", + "libp2p-core", + "libp2p-identity", + "multiaddr", + "multihash", + "quick-protobuf", + "rand 0.8.5", + "snow", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "libp2p-quic" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.5", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-request-response" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478" +dependencies = [ + "async-trait", + "cbor4ii", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.5", + "serde", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "hashlink 0.10.0", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "multistream-select", + "rand 0.8.5", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" +dependencies = [ + "heck", + "quote", + "syn", +] + +[[package]] +name = "libp2p-tcp" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.6.3", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" +dependencies = [ + "futures", + "futures-timer", + "igd-next", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-yamux" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8" +dependencies = [ + "either", + "futures", + "libp2p-core", + "thiserror 2.0.18", + "tracing", + "yamux 0.12.1", + "yamux 0.13.10", +] + +[[package]] +name = "linkme" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1432,6 +2506,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1468,6 +2553,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.4" @@ -1488,6 +2579,127 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multiaddr" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "libp2p-identity", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint 0.8.0", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +dependencies = [ + "core2", + "serde", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multistream-select" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +dependencies = [ + "bytes", + "futures", + "log", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + [[package]] name = "network-interface" version = "1.1.4" @@ -1500,6 +2712,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1509,6 +2749,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1541,7 +2806,7 @@ dependencies = [ "hyper", "hyper-util", "hyperlocal", - "if-addrs", + "if-addrs 0.13.4", "libc", "random-port", "serde", @@ -1571,11 +2836,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1583,6 +2861,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1628,12 +2912,48 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1657,6 +2977,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polling" version = "3.11.0" @@ -1671,6 +3001,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1680,6 +3039,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1709,12 +3074,57 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" dependencies = [ - "unicode-ident", + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", ] [[package]] @@ -1725,12 +3135,13 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -1768,7 +3179,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -1864,6 +3275,19 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1873,6 +3297,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1930,6 +3366,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -1944,6 +3386,43 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1956,6 +3435,24 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1977,6 +3474,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2050,6 +3548,17 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2216,6 +3725,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2225,6 +3745,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared" +version = "0.1.0" +dependencies = [ + "kameo", + "libp2p", + "tokio", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2241,6 +3770,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -2253,6 +3791,33 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core 0.6.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2263,6 +3828,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable-eyre" version = "0.2.2" @@ -2280,6 +3855,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2344,6 +3925,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -2406,6 +3993,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2443,7 +4061,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -2687,6 +4305,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2699,6 +4329,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + [[package]] name = "untrusted" version = "0.9.0" @@ -2738,9 +4390,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2925,6 +4577,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2956,6 +4614,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2969,6 +4648,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2997,6 +4687,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3119,6 +4819,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -3369,6 +5078,90 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yamux" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot", + "pin-project", + "rand 0.8.5", + "static_assertions", +] + +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot", + "pin-project", + "rand 0.9.2", + "static_assertions", + "web-time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -3510,6 +5303,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 6d8e289..900a991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["odorobo-agent", "odoroboctl"] +members = ["odorobo-agent", "odoroboctl", "shared"] [workspace.dependencies] stable-eyre = "0.2.2" diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..cdfc97d --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" + +[dependencies] +kameo = { version = "0.19.2", features = ["remote"] } +tokio = { version = "1.50.0", features = ["full"] } +libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..01c5406 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,89 @@ +pub mod utils; + +use kameo::prelude::*; +use libp2p::{mdns, noise, tcp, yamux, PeerId}; +use libp2p::futures::StreamExt; +use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; +use crate::utils::DynError; + +#[derive(NetworkBehaviour)] +pub struct ProductionBehaviour { + kameo: remote::Behaviour, + mdns: mdns::tokio::Behaviour, +} + +// based on: +// https://github.com/tqwewe/kameo/blob/main/examples/custom_swarm.rs +// https://docs.page/tqwewe/kameo/distributed-actors/custom-swarm-configuration +pub fn connect_to_swarm() -> Result { + let mut swarm = libp2p::SwarmBuilder::with_new_identity() + .with_tokio() + .with_tcp(tcp::Config::default(), noise::Config::new, yamux::Config::default)? + .with_behaviour(|key| { + let local_peer_id = key.public().to_peer_id(); + + let kameo = remote::Behaviour::new( + local_peer_id, + remote::messaging::Config::default(), + ); + + let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)?; + Ok(ProductionBehaviour { kameo, mdns }) + })? + .build(); + + // Initialize Kameo's global registry + swarm.behaviour().kameo.init_global(); + + // Listen on a specific address + swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?; + + let local_peer_id = *swarm.local_peer_id(); + + println!("Local peer id: {:?}", local_peer_id); + + // Spawn the swarm task + tokio::spawn(async move { + loop { + match swarm.select_next_some().await { + // Handle mDNS discovery + SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Discovered(list))) => { + for (peer_id, multiaddr) in list { + println!("mDNS discovered peer: {peer_id}"); + swarm.add_peer_address(peer_id, multiaddr); + } + } + SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Expired(list))) => { + for (peer_id, _) in list { + println!("mDNS peer expired: {peer_id}"); + let _ = swarm.disconnect_peer_id(peer_id); + } + } + // Handle Kameo events (optional - for monitoring) + SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Registry( + registry_event, + ))) => { + println!("Registry event: {:?}", registry_event); + } + SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Messaging( + messaging_event, + ))) => { + println!("Messaging event: {:?}", messaging_event); + } + // Handle other swarm events + SwarmEvent::NewListenAddr { address, .. } => { + println!("Listening on {address}"); + } + SwarmEvent::ConnectionEstablished { peer_id, .. } => { + println!("Connected to {peer_id}"); + } + SwarmEvent::ConnectionClosed { peer_id, cause, .. } => { + println!("Disconnected from {peer_id}: {cause:?}"); + } + _ => {} + } + } + }); + + Ok(local_peer_id) +} \ No newline at end of file diff --git a/shared/src/utils.rs b/shared/src/utils.rs new file mode 100644 index 0000000..96a7fe3 --- /dev/null +++ b/shared/src/utils.rs @@ -0,0 +1 @@ +pub type DynError = Box; \ No newline at end of file From f89035248ec4b04a377ba0a514b72edb5ec72b83 Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 13:32:06 -0500 Subject: [PATCH 054/189] merge scheduler from private prototype repo. --- Cargo.lock | 119 ++++++++- Cargo.toml | 2 +- odorobo-scheduler/Cargo.toml | 28 ++ odorobo-scheduler/src/bin/gen_openapi_spec.rs | 7 + .../src/bin/odorobo-scheduler.rs | 16 ++ odorobo-scheduler/src/lib.rs | 2 + odorobo-scheduler/src/scheduler_actor.rs | 246 ++++++++++++++++++ {shared => odorobo-shared}/Cargo.toml | 4 +- odorobo-shared/src/kameo_messages.rs | 13 + {shared => odorobo-shared}/src/lib.rs | 1 + {shared => odorobo-shared}/src/utils.rs | 0 11 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 odorobo-scheduler/Cargo.toml create mode 100644 odorobo-scheduler/src/bin/gen_openapi_spec.rs create mode 100644 odorobo-scheduler/src/bin/odorobo-scheduler.rs create mode 100644 odorobo-scheduler/src/lib.rs create mode 100644 odorobo-scheduler/src/scheduler_actor.rs rename {shared => odorobo-shared}/Cargo.toml (67%) create mode 100644 odorobo-shared/src/kameo_messages.rs rename {shared => odorobo-shared}/src/lib.rs (99%) rename {shared => odorobo-shared}/src/utils.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 7c5eaa3..39e4c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -380,6 +382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -453,6 +456,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "axum_responses" version = "0.5.5" @@ -1131,6 +1145,15 @@ dependencies = [ "syn", ] +[[package]] +name = "env" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc95de49ad098572c02d3fbf368c9a020bfff5ae78483685b77f51d8a7e9486d" +dependencies = [ + "num_threads", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2783,6 +2806,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.2" @@ -2822,6 +2854,39 @@ dependencies = [ "zbus_systemd", ] +[[package]] +name = "odorobo-scheduler" +version = "0.1.0" +dependencies = [ + "ahash", + "axum", + "env", + "kameo", + "libp2p", + "odorobo-shared", + "optional_struct", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "uuid", +] + +[[package]] +name = "odorobo-shared" +version = "0.1.0" +dependencies = [ + "kameo", + "libp2p", + "serde", + "tokio", + "utoipa", +] + [[package]] name = "odoroboctl" version = "0.1.0" @@ -2873,6 +2938,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "optional_struct" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14199f59efce6ed2c5854f0abc725c32eedfbd02c6ef82c9733c726f3fc6dc91" +dependencies = [ + "optional_struct_macro", + "serde", +] + +[[package]] +name = "optional_struct_macro" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5eba042d9efe5e108e0df9ce2f85c540fc4f94f41c6821cbcf70ed47c1221da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3745,15 +3831,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shared" -version = "0.1.0" -dependencies = [ - "kameo", - "libp2p", - "tokio", -] - [[package]] name = "shlex" version = "1.3.0" @@ -4388,6 +4465,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "uuid", +] + [[package]] name = "uuid" version = "1.23.0" diff --git a/Cargo.toml b/Cargo.toml index 900a991..14e4921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["odorobo-agent", "odoroboctl", "shared"] +members = ["odorobo-agent", "odoroboctl", "odorobo-scheduler", "odorobo-shared"] [workspace.dependencies] stable-eyre = "0.2.2" diff --git a/odorobo-scheduler/Cargo.toml b/odorobo-scheduler/Cargo.toml new file mode 100644 index 0000000..30dcff9 --- /dev/null +++ b/odorobo-scheduler/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "odorobo-scheduler" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "odorobo-scheduler" + +[[bin]] +name = "gen_openapi_spec" + +[dependencies] +kameo = { version = "0.19.2", features = ["remote"] } +axum = { version = "0.8.8", features = ["tracing", "macros"] } +env = "1.0.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.50.0", features = ["full"] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. +tower-http = { version = "0.6.1", features = ["trace"] } +tracing = "0.1.44" +optional_struct = "0.5.2" +tracing-subscriber = { version = "0.3.2", features = ["env-filter"] } +uuid = { version = "1.23.0", features = ["serde"]} +libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } +reqwest = { version = "0.13", features = ["json"] } +ahash = { version = "0.8.12", features = ["serde"] } +utoipa = { version = "5.4.0", features=["uuid"] } +odorobo-shared = { path = "../odorobo-shared" } \ No newline at end of file diff --git a/odorobo-scheduler/src/bin/gen_openapi_spec.rs b/odorobo-scheduler/src/bin/gen_openapi_spec.rs new file mode 100644 index 0000000..eb5f75d --- /dev/null +++ b/odorobo-scheduler/src/bin/gen_openapi_spec.rs @@ -0,0 +1,7 @@ +use std::fs; +use odorobo_scheduler::scheduler_actor::gen_openapi_spec; + +fn main() -> std::io::Result<()> { + let doc = gen_openapi_spec(); + fs::write("./openapi_spec.json", doc) +} \ No newline at end of file diff --git a/odorobo-scheduler/src/bin/odorobo-scheduler.rs b/odorobo-scheduler/src/bin/odorobo-scheduler.rs new file mode 100644 index 0000000..ad0b614 --- /dev/null +++ b/odorobo-scheduler/src/bin/odorobo-scheduler.rs @@ -0,0 +1,16 @@ +use kameo::prelude::*; +use odorobo_shared::utils::DynError; +use odorobo_scheduler::scheduler_actor::SchedulerActor; +use odorobo_shared::connect_to_swarm; + +#[tokio::main] +async fn main() -> Result<(), DynError> { + let _local_peer_id = connect_to_swarm()?; + + let actor_ref = SchedulerActor::spawn(SchedulerActor {}); + actor_ref.register("scheduler").await?; + + actor_ref.wait_for_shutdown().await; + + Ok(()) +} \ No newline at end of file diff --git a/odorobo-scheduler/src/lib.rs b/odorobo-scheduler/src/lib.rs new file mode 100644 index 0000000..e887c65 --- /dev/null +++ b/odorobo-scheduler/src/lib.rs @@ -0,0 +1,2 @@ + +pub mod scheduler_actor; \ No newline at end of file diff --git a/odorobo-scheduler/src/scheduler_actor.rs b/odorobo-scheduler/src/scheduler_actor.rs new file mode 100644 index 0000000..5eb85bb --- /dev/null +++ b/odorobo-scheduler/src/scheduler_actor.rs @@ -0,0 +1,246 @@ +use axum::{Json, Router}; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use kameo::prelude::*; +use libp2p::futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use libp2p::PeerId; +use utoipa::OpenApi; +use odorobo_shared::kameo_messages::{ServerStatus, GetServerStatus}; +//use odorobo_shared::odorobo::server_actor::ServerActor; +use odorobo_shared::utils::DynError; + +#[derive(RemoteActor)] +pub struct SchedulerActor { } + +const PING_RETURN_VALUE: &str = "pong"; +const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; + +const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: make this based on EXTERNAL_HTTP_ADDRESS. const compile time stuff is a pain. + + +impl Actor for SchedulerActor { + type Args = Self; + type Error = DynError; + + async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + let axum_router = Router::new() + .route("/ping", get(|| async { PING_RETURN_VALUE })) + .route("/create_vm", post(create_vm)) + .route("/delete_vm", post(delete_vm)) + .route("/update_vm", post(update_vm)) + .route("/get_vm", post(get_vm)) + .route("/drain_server", post(drain_server)) + .route("/get_servers", post(get_servers)) + .with_state(actor_ref); + + + println!("starting axum server at {}", EXTERNAL_HTTP_URL); + + // run our app with hyper, listening globally on port 3000 + tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS).await.unwrap(); + axum::serve(listener, axum_router).await.unwrap(); + }); + + // spin loop until the axum server starts responding to requests + // TODO: if anyone has a better way to detect the axum server is up, change it to that. + + let mut count = 0; + loop { + count += 1; + println!("attempting to hit axum server, attempt {}", count); + + let resp_result: Result<(), DynError> = async { + let resp = reqwest::get(EXTERNAL_HTTP_URL.to_owned() + "/ping") + .await? + .text() + .await?; + + if resp != PING_RETURN_VALUE { + return Err("invalid ping response".into()); + } + + Ok(()) + }.await; + + match resp_result { + Ok(()) => { + break; + }, + Err(e) => { + println!("{}", e) + } + } + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + println!("Actor started"); + Ok(state) + } +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct CreateVM { + pub uuid: Uuid, + pub name: String, + pub vcpus: u32, + pub ram: u32, + pub image: String, +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GenericSuccessResponse { + pub success: bool, +} + +// no response. just use status code 200 vs not 200 for if it worked. +#[utoipa::path( + post, + path = "/create_vm", + request_body(content = CreateVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +pub async fn create_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + todo!() +} + +pub type UpdateVM = CreateVM; + +#[utoipa::path( + post, + path = "/update_vm", + request_body(content = UpdateVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn update_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + todo!() +} + + +pub type DeleteVM = GetVM; +#[utoipa::path( + post, + path = "/delete_vm", + request_body(content = DeleteVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn delete_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + todo!() +} + + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetVM { + pub uuid: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetVMResponse { + pub cpus: bool +} +#[utoipa::path( + post, + path = "/get_vm", + request_body(content = GetVM, content_type = "application/json"), + responses( + (status = 200, body = GetVMResponse) + ) +)] +async fn get_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + todo!() +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct DrainServer {} + +#[utoipa::path( + post, + path = "/get_vm", + request_body(content = DrainServer, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn drain_server(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + todo!() +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetServers { + pub start_index: u64, + pub end_index: u64 +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetServersResponse { + pub total_servers: u64, + pub servers: Vec, +} + + +#[utoipa::path( + post, + path = "/get_servers", + request_body(content = GetServers, content_type = "application/json"), + responses( + (status = 200, body = GetServersResponse) + ) +)] +async fn get_servers(State(state): State>, Json(payload): Json) -> (StatusCode, String) { + /* + let mut servers: Vec<(PeerId, ServerStatus)> = Vec::new(); + + let server_actor_response: Result<(), DynError> = async { + println!("getting server actors"); + + let mut server_actors = RemoteActorRef::::lookup_all("server"); + + while let Some(server_actor) = server_actors.try_next().await? { + // Send message to each instance + println!("asking {:?}", server_actor); + let result = server_actor.ask(&GetServerStatus { }).await?; + println!("result {:?}", result); + + if let Some(peerId) = server_actor.id().peer_id() { + servers.push((*peerId, result)); + } + } + + Ok(()) + }.await; + + match server_actor_response { + Ok(()) => { + (StatusCode::OK, serde_json::to_string(&servers).unwrap()) + }, + _ => { + (StatusCode::INTERNAL_SERVER_ERROR, "".parse().unwrap()) + } + } + + */ + todo!() +} + +pub fn gen_openapi_spec() -> String { + #[derive(OpenApi)] + #[openapi( + components( + schemas(CreateVM, UpdateVM, DeleteVM, GetVM, DrainServer, GetServers, GenericSuccessResponse, GetVMResponse, GetServersResponse) + ), + paths(get_servers) + )] + struct ApiDoc; + + ApiDoc::openapi().to_pretty_json().unwrap() +} \ No newline at end of file diff --git a/shared/Cargo.toml b/odorobo-shared/Cargo.toml similarity index 67% rename from shared/Cargo.toml rename to odorobo-shared/Cargo.toml index cdfc97d..a0672b8 100644 --- a/shared/Cargo.toml +++ b/odorobo-shared/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "shared" +name = "odorobo-shared" version = "0.1.0" edition = "2024" license = "AGPL-3.0-or-later" @@ -8,3 +8,5 @@ license = "AGPL-3.0-or-later" kameo = { version = "0.19.2", features = ["remote"] } tokio = { version = "1.50.0", features = ["full"] } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } +serde = { version = "1.0.228", features = ["derive"] } +utoipa = { version = "5.4.0", features=["uuid"] } diff --git a/odorobo-shared/src/kameo_messages.rs b/odorobo-shared/src/kameo_messages.rs new file mode 100644 index 0000000..0e704bc --- /dev/null +++ b/odorobo-shared/src/kameo_messages.rs @@ -0,0 +1,13 @@ +use std::fmt::{Display, Formatter}; +use kameo::Reply; +use libp2p::PeerId; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct GetServerStatus {} + +#[derive(Serialize, Deserialize, Reply, Debug, utoipa::ToSchema)] +pub struct ServerStatus { + pub vcpus: u32, + pub ram: u32 +} \ No newline at end of file diff --git a/shared/src/lib.rs b/odorobo-shared/src/lib.rs similarity index 99% rename from shared/src/lib.rs rename to odorobo-shared/src/lib.rs index 01c5406..af9f1c4 100644 --- a/shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -1,4 +1,5 @@ pub mod utils; +pub mod kameo_messages; use kameo::prelude::*; use libp2p::{mdns, noise, tcp, yamux, PeerId}; diff --git a/shared/src/utils.rs b/odorobo-shared/src/utils.rs similarity index 100% rename from shared/src/utils.rs rename to odorobo-shared/src/utils.rs From fce49f2835034e4f0501260990f687624d606eea Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 13:34:31 -0500 Subject: [PATCH 055/189] Add .DS_Store to .gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cf82bef..cf495b1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ dev # everyone keeps their own LLM configs AGENTS.md CLAUDE.md -.claude/ \ No newline at end of file +.claude/ + + +# mac bullshit +.DS_Store \ No newline at end of file From e6d12721d2d0189ef66af2160bf06de4196880fe Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 5 Apr 2026 23:22:28 -0500 Subject: [PATCH 056/189] way too much stuff --- Cargo.lock | 74 +++++ Cargo.toml | 17 +- HACKING.md | 20 +- docs/kademlia_dht.md | 13 + docs/scheduler_routes.md | 33 ++ odorobo-agent/Cargo.toml | 23 +- odorobo-agent/config.json | 4 + odorobo-agent/src/actor/mod.rs | 133 ++++++++ odorobo-agent/src/lib.rs | 4 + odorobo-agent/src/main.rs | 51 ++- odorobo-agent/src/state/mod.rs | 2 +- .../src/state/provisioning/actor/mod.rs | 58 ++++ odorobo-agent/src/state/provisioning/mod.rs | 1 + odorobo-scheduler/Cargo.toml | 17 +- odorobo-scheduler/src/bin/gen_openapi_spec.rs | 7 - .../src/bin/odorobo-scheduler.rs | 6 +- odorobo-scheduler/src/scheduler_actor.rs | 243 ++------------ odorobo-scheduler/src/scheduler_actor_http.rs | 310 ++++++++++++++++++ odorobo-shared/Cargo.toml | 14 +- odorobo-shared/src/error.rs | 8 + odorobo-shared/src/lib.rs | 39 ++- odorobo-shared/src/messages/create_vm.rs | 30 ++ odorobo-shared/src/messages/debug.rs | 9 + odorobo-shared/src/messages/mod.rs | 3 + .../server_status.rs} | 8 +- odorobo-shared/src/utils.rs | 28 +- 26 files changed, 877 insertions(+), 278 deletions(-) create mode 100644 docs/kademlia_dht.md create mode 100644 docs/scheduler_routes.md create mode 100644 odorobo-agent/config.json create mode 100644 odorobo-agent/src/actor/mod.rs create mode 100644 odorobo-agent/src/lib.rs create mode 100644 odorobo-agent/src/state/provisioning/actor/mod.rs delete mode 100644 odorobo-scheduler/src/bin/gen_openapi_spec.rs create mode 100644 odorobo-scheduler/src/scheduler_actor_http.rs create mode 100644 odorobo-shared/src/error.rs create mode 100644 odorobo-shared/src/messages/create_vm.rs create mode 100644 odorobo-shared/src/messages/debug.rs create mode 100644 odorobo-shared/src/messages/mod.rs rename odorobo-shared/src/{kameo_messages.rs => messages/server_status.rs} (50%) diff --git a/Cargo.lock b/Cargo.lock index 39e4c77..b9d7b2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,6 +614,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cbor4ii" version = "0.3.3" @@ -2763,6 +2769,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2815,6 +2830,25 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.32.2" @@ -2828,10 +2862,12 @@ dependencies = [ name = "odorobo-agent" version = "0.1.0" dependencies = [ + "ahash", "async-trait", "axum", "axum-extra", "axum_responses", + "bytesize", "cloud-hypervisor-client", "futures-util", "http-body-util", @@ -2839,16 +2875,21 @@ dependencies = [ "hyper-util", "hyperlocal", "if-addrs 0.13.4", + "kameo", "libc", + "libp2p", + "odorobo-shared", "random-port", "serde", "serde_json", "stable-eyre", + "sysinfo", "thiserror 2.0.18", "tokio", "tower-http", "tracing", "tracing-subscriber", + "ulid", "url", "zbus", "zbus_systemd", @@ -2863,11 +2904,13 @@ dependencies = [ "env", "kameo", "libp2p", + "odorobo-agent", "odorobo-shared", "optional_struct", "reqwest", "serde", "serde_json", + "stable-eyre", "tokio", "tower-http", "tracing", @@ -2880,10 +2923,16 @@ dependencies = [ name = "odorobo-shared" version = "0.1.0" dependencies = [ + "cloud-hypervisor-client", "kameo", "libp2p", "serde", + "stable-eyre", + "thiserror 2.0.18", "tokio", + "tracing", + "tracing-subscriber", + "ulid", "utoipa", ] @@ -3981,6 +4030,20 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -4394,6 +4457,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "serde", + "web-time", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 14e4921..955357b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,12 +5,25 @@ members = ["odorobo-agent", "odoroboctl", "odorobo-scheduler", "odorobo-shared"] [workspace.dependencies] stable-eyre = "0.2.2" tracing = "0.1" -tracing-subscriber = {version = "0.3", features = ["env-filter", "fmt", "json"] } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "fmt", + "json", +] } cloud-hypervisor-client = "0.3" serde_json = "1.0.149" serde = "1.0" axum = { version = "0.8.8", features = ["ws"] } -axum-responses = "0.5.5" +thiserror = "2.0.18" axum-extra = "0.12" tower-http = { version = "0.6", features = ["trace"] } tokio = { version = "1.50.0" } +kameo = { version = "0.19.2", features = ["remote"] } +libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } +ahash = { version = "0.8.12", features = ["serde"] } +ulid = { version = "1.2", features = ["serde"] } + +odorobo-shared = { path = "odorobo-shared" } +odorobo-agent = { path = "odorobo-agent" } +odoroboctl = { path = "odoroboctl" } +odorobo-scheduler = { path = "odorobo-scheduler" } diff --git a/HACKING.md b/HACKING.md index 36a86c9..b0e98bd 100644 --- a/HACKING.md +++ b/HACKING.md @@ -55,4 +55,22 @@ If your trait needs to do dynamic dispatch (e.g. for provisioning hooks), you mu ## Rust Hypervisor Firmware failing to boot newer images -out of scope for odorobo, tracking issue [here](https://github.com/cloud-hypervisor/rust-hypervisor-firmware/issues/412) \ No newline at end of file +out of scope for odorobo, tracking issue [here](https://github.com/cloud-hypervisor/rust-hypervisor-firmware/issues/412) + +## creating kameo handlers + +You need to impl a `Message` trait for the actor to be able to handle a message on an actor. The following is a template for this + +You also need to implement + +```rs +#[remote_message] +impl Message for Actor { + type Reply = ReplyType; + + async fn handle(&mut self, msg: RequestMessageType, _ctx: &mut Context) -> Self::Reply { + // this code will run whenever someone sends the actor this type of message. + ReplyType {} + } +} +``` \ No newline at end of file diff --git a/docs/kademlia_dht.md b/docs/kademlia_dht.md new file mode 100644 index 0000000..3c1a7f0 --- /dev/null +++ b/docs/kademlia_dht.md @@ -0,0 +1,13 @@ +# kademlia dht + +the cluster uses a dht for node lookups and for any global state we store + +### quorum/replication factor + +We will use majority quorum, but we have to be careful about if we lose enough nodes. + +basically if a server goes down and we only run dht actors/nodes on the compute nodes, we currently would lose 1/8 of the cluster. + +This means we need to make sure that our replication factor is high enough that the percent chance of any key in the dht not losing majority quorum + +this will be less of a problem as we have more servers that can run dht nodes. one thing that may help is running some dht nodes on the \ No newline at end of file diff --git a/docs/scheduler_routes.md b/docs/scheduler_routes.md new file mode 100644 index 0000000..a1e62f9 --- /dev/null +++ b/docs/scheduler_routes.md @@ -0,0 +1,33 @@ +# scheduler routes + +this is a very rough definition of what routes we will have + +## vms + +we need CRUD. we keep track of if its active. + +MACs for VMs need to be stored somewhere. it can be either in the DHT or in the Dashboard. doesnt matter. + +## volumes + +we again need CRUD, and we keep track of the volumes cause we want to just check what is in the SAN + +## coloc + +we need CRUD for ip to mac address assignment. this will be used in the admin management portal by infra people (ex: kathrine) to assign an ip to coloc when they rack servers. + +the dashboard needs to keep track of both coloc and vps ip assignments because we don't want to have to get all the data from the router everytime we do an ip assignment. we still need possibly two routes to get all mac/ip assignments from the router and a single assignment, to be able to verify the data is accurate in the dashboard db. there likely should be a job that runs every so often that verifies all this info and makes sure nothing is out of date. + +In addition, we would like the ability to get a list of all MACs on the router, and then filter them by the known MACs (VPSes, our servers, and known colocs), and then display only the unknown macs on the dashboard. this should make it very easy for infra people to rack a server, they see the MAC that we have never seen before and hopefully has a first seen time associated of right now, and then they just use that one when they do the server has been racked admin managment form + +## other various things to do + +socket terminal stuff via websockets + +drain server so infra people can migrate all VMs off a server so we can take it down for maintence + +they also need a get all servers so they can view the list of servers. this needs to be able show data from the infra config file, and possibly will give any metrics we can reasonably get from sys_info. the metrics/extra info should possibly be a different route. possibly ask addison + +## removing openapi spec + +the openapi spec is a pain because of utopia, so addison has told us that we don't need to give her a full spec file. If we just give her a file of rust structs, labeled similar to she is fine with that. \ No newline at end of file diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 618e9d9..e2f8a29 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -11,14 +11,19 @@ tracing-subscriber = { workspace = true } cloud-hypervisor-client = { workspace = true } http-body-util = "0.1.3" hyper = { version = "1.8.1", features = ["full"] } -hyper-util = { version = "0.1.20", features = ["client", "client-legacy", "http1", "http2"] } +hyper-util = { version = "0.1.20", features = [ + "client", + "client-legacy", + "http1", + "http2", +] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } hyperlocal = "0.9.1" tokio = { workspace = true, features = ["full"] } axum = { workspace = true, features = ["ws"] } -axum-extra = { workspace = true, features = ["query"] } axum_responses = "0.5.5" +axum-extra = { workspace = true, features = ["query"] } thiserror = "2.0.18" futures-util = "0.3.31" libc = "0.2" @@ -26,7 +31,17 @@ tower-http = { workspace = true, features = ["trace"] } random-port = "0.1.1" if-addrs = "0.13" zbus = "5.14.0" -zbus_systemd = { version = "0.26000.0", features = ["machine1", "systemd1", "zbus-async-tokio"] } +zbus_systemd = { version = "0.26000.0", features = [ + "machine1", + "systemd1", + "zbus-async-tokio", +] } url = { version = "2.5.8", features = ["serde"] } async-trait = "0.1.89" - +kameo = { workspace = true } +libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } +ahash = { workspace = true } +sysinfo = "0.38.4" +bytesize = "2.3.1" +odorobo-shared = { workspace = true } +ulid = { workspace = true } \ No newline at end of file diff --git a/odorobo-agent/config.json b/odorobo-agent/config.json new file mode 100644 index 0000000..bc5ed7e --- /dev/null +++ b/odorobo-agent/config.json @@ -0,0 +1,4 @@ +{ + "datacenter": "Dev", + "region": "Dev" +} \ No newline at end of file diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs new file mode 100644 index 0000000..340d313 --- /dev/null +++ b/odorobo-agent/src/actor/mod.rs @@ -0,0 +1,133 @@ +use crate::state::provisioning::actor::VMActor; +use ahash::AHashMap; +use bytesize::ByteSize; +use kameo::prelude::*; +use odorobo_shared::messages::create_vm::*; +use odorobo_shared::messages::debug::PanicAgent; +use serde::{Deserialize, Serialize}; +use stable_eyre::{Report, Result}; +use std::{fs, path::PathBuf}; +use sysinfo::System; +use std::ops::ControlFlow; +use kameo::error::ActorStopReason; + +use kameo::error::PanicError; + +#[derive(RemoteActor)] +pub struct AgentActor { + pub vcpus: u32, + pub memory: ByteSize, + pub config: Config, +} + +/// Gets the system hostname +pub fn hostname() -> String { + System::host_name().unwrap_or("odorobo".into()) +} + +/// This was requested by katherine. Do not change without asking her. +pub fn default_reserved_vcpus() -> u32 { + 2 +} + +// The infra team wants a config file on the box where they can set info specific for the box its on. +// TODO: Double check with infra team (katherine) if they want any other config on the box. +#[derive(Serialize, Deserialize)] +pub struct Config { + /// The hostname of the agent. Defaults to the system hostname + /// if not specified in the config file. + #[serde(default = "hostname")] + pub hostname: String, + /// The datacenter the agent is running in. + pub datacenter: String, + /// The region the agent is running in. + pub region: String, + /// The number of VCPUs reserved for the agent. Defaults to 2. + #[serde(default = "default_reserved_vcpus")] + pub reserved_vcpus: u32, + /// this is just arbitrary data that will be shown but does no config + /// Arbitrary labels that can be used + #[serde(default)] + pub labels: AHashMap, + /// Arbitrary annotations that can be used + #[serde(default)] + pub annotations: AHashMap, +} + +impl Actor for AgentActor { + type Args = (); + type Error = Report; + + async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + // TODO: ask infra team where they want this on the box + let file = fs::File::open("config.json").expect("file should open read only"); + let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); + + let sys = System::new_all(); + + Ok(AgentActor { + vcpus: sys.cpus().len() as u32, + memory: ByteSize::b(sys.total_memory()), + config, + }) + } + + // async fn on_panic(state: Self::Args, weak_actor_ref: WeakActorRef, _panic: &PanicError) { + // panic!("Agent panicked: {:?}", _panic); + // } + // + async fn on_panic( + &mut self, + actor_ref: WeakActorRef, + err: PanicError, + ) -> Result> { + println!("Agent panicked: {:?}", err); + + // todo: if we panic, we should completely regen the self struct from scratch. The assumption should be that memory corruption could have possibly happened becauew + + Ok(ControlFlow::Continue(())) + } +} + +#[remote_message] +impl Message for AgentActor { + type Reply = CreateVMReply; + + async fn handle( + &mut self, + msg: CreateVM, + _ctx: &mut Context, + ) -> Self::Reply { + // TODO: this is unfinished. we intend on using the state::provisioning::actor stuff for this I think. + let vmid = ulid::Ulid::new(); + let actor_ref = VMActor::spawn(VMActor { + ch_socket_path: PathBuf::from(format!("/run/odorobo/vms/{}/ch.sock", vmid)), + vmid, + vm_config: Default::default(), + }); + + let actor_registration_result = actor_ref.register("vm").await; + + tracing::info!("someone asked us for available capacity"); + CreateVMReply { + config: Default::default(), + } + } +} + +#[remote_message] +impl Message for AgentActor { + type Reply = (); + + async fn handle( + &mut self, + msg: PanicAgent, + _ctx: &mut Context, + ) -> Self::Reply { + tracing::info!("panicking"); + panic!(); + } +} + +#[cfg(test)] +mod tests {} diff --git a/odorobo-agent/src/lib.rs b/odorobo-agent/src/lib.rs new file mode 100644 index 0000000..6f437fc --- /dev/null +++ b/odorobo-agent/src/lib.rs @@ -0,0 +1,4 @@ +pub mod actor; +mod api; +mod state; +mod util; \ No newline at end of file diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 511b25b..0130485 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,45 +1,40 @@ +pub mod actor; mod api; mod state; mod util; +use kameo::actor::Spawn; +use odorobo_shared::connect_to_swarm; use stable_eyre::Result; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; - -fn env_filter() -> EnvFilter { - let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); - - let base = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .parse_lossy(&env); - - #[cfg(debug_assertions)] - let base = base.add_directive("odorobo_agent=trace".parse().unwrap()); - - base -} +// use odorobo_shared:: +use crate::actor::AgentActor; #[tokio::main] async fn main() -> Result<()> { - stable_eyre::install()?; - tracing_subscriber::fmt() - .with_env_filter(env_filter()) - .with_file(true) - .with_line_number(true) - .init(); + odorobo_shared::utils::init()?; tracing::info!("Starting odorobo-agent..."); // minimal axum server - let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; - let port = listener.local_addr()?.port(); - let addrs: Vec = if_addrs::get_if_addrs()? - .into_iter() - .filter(|i| !i.is_loopback()) - .map(|i| format!("http://{}:{}", i.ip(), port)) - .collect(); - tracing::info!(port, ?addrs, "Listening"); - axum::serve(listener, api::router(port)).await?; + let local_peer_id = connect_to_swarm().unwrap(); + tracing::info!(?local_peer_id, "Peer ID"); + + let actor_ref = AgentActor::spawn(()); + actor_ref.register("agent").await?; + + actor_ref.wait_for_shutdown().await; + + // let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; + // let port = listener.local_addr()?.port(); + // let addrs: Vec = if_addrs::get_if_addrs()? + // .into_iter() + // .filter(|i| !i.is_loopback()) + // .map(|i| format!("http://{}:{}", i.ip(), port)) + // .collect(); + // tracing::info!(port, ?addrs, "Listening"); + // axum::serve(listener, api::router(port)).await?; Ok(()) } diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 7f56ead..0cb95d9 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -6,7 +6,7 @@ mod api; mod instance; mod devices; -mod provisioning; +pub mod provisioning; mod transform; pub use api::{call, call_request}; diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs new file mode 100644 index 0000000..b8870ea --- /dev/null +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -0,0 +1,58 @@ +use super::VMProvisionerBackend; +use crate::state::VMInstance; +use cloud_hypervisor_client::models::VmConfig; +use kameo::prelude::*; +use stable_eyre::Report; +use stable_eyre::{Result}; +use std::path::PathBuf; /* +use std::process::Command; + +let output = Command::new("echo") +.arg("Hello world") +.output() +.expect("Failed to execute command"); + */ + +#[derive(RemoteActor)] +pub struct VMActor { + pub vmid: ulid::Ulid, + /// Pre-transform config, transformed config goes into the CH instance itself + pub vm_config: VmConfig, + /// path to the Cloud Hypervisor socket, in /run/odorobo/vms//ch.sock + pub ch_socket_path: PathBuf, +} + +impl Actor for VMActor { + type Args = Self; + type Error = Report; + + #[tracing::instrument(skip_all)] + async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + + tracing::warn!("no-op"); + Ok(state) + } +} + +// allow conversion from VMActor to VMInstance to call API +impl From for VMInstance { + fn from(actor: VMActor) -> Self { + Self { + id: actor.vmid.into(), + ch_socket_path: actor.ch_socket_path, + } + } +} + +/// Provisioner backend for VM instances using an actor-based model +pub struct ActorProvisioner; + +impl VMProvisionerBackend for ActorProvisioner { + async fn start_instance(&self, vmid: &str) -> Result { + todo!() + } + + async fn stop_instance(&self, vmid: &str) -> Result<()> { + todo!() + } +} diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs index 0448921..552421d 100644 --- a/odorobo-agent/src/state/provisioning/mod.rs +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -4,6 +4,7 @@ //! Cloud Hypervisor process for a given instance mod hooks; mod systemd; +pub mod actor; use cloud_hypervisor_client::models::VmConfig; use stable_eyre::{Result, eyre::Context}; use tracing::info; diff --git a/odorobo-scheduler/Cargo.toml b/odorobo-scheduler/Cargo.toml index 30dcff9..702cca0 100644 --- a/odorobo-scheduler/Cargo.toml +++ b/odorobo-scheduler/Cargo.toml @@ -6,23 +6,24 @@ edition = "2024" [[bin]] name = "odorobo-scheduler" -[[bin]] -name = "gen_openapi_spec" - [dependencies] kameo = { version = "0.19.2", features = ["remote"] } axum = { version = "0.8.8", features = ["tracing", "macros"] } env = "1.0.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -tokio = { version = "1.50.0", features = ["full"] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. -tower-http = { version = "0.6.1", features = ["trace"] } +tokio = { version = "1.50.0", features = [ + "full", +] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. +tower-http = { version = "0.6.8", features = ["trace"] } tracing = "0.1.44" optional_struct = "0.5.2" tracing-subscriber = { version = "0.3.2", features = ["env-filter"] } -uuid = { version = "1.23.0", features = ["serde"]} +uuid = { version = "1.23.0", features = ["serde"] } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } reqwest = { version = "0.13", features = ["json"] } ahash = { version = "0.8.12", features = ["serde"] } -utoipa = { version = "5.4.0", features=["uuid"] } -odorobo-shared = { path = "../odorobo-shared" } \ No newline at end of file +utoipa = { version = "5.4.0", features = ["uuid"] } +odorobo-shared = { workspace = true } +stable-eyre = { workspace = true } +odorobo-agent = { workspace = true } diff --git a/odorobo-scheduler/src/bin/gen_openapi_spec.rs b/odorobo-scheduler/src/bin/gen_openapi_spec.rs deleted file mode 100644 index eb5f75d..0000000 --- a/odorobo-scheduler/src/bin/gen_openapi_spec.rs +++ /dev/null @@ -1,7 +0,0 @@ -use std::fs; -use odorobo_scheduler::scheduler_actor::gen_openapi_spec; - -fn main() -> std::io::Result<()> { - let doc = gen_openapi_spec(); - fs::write("./openapi_spec.json", doc) -} \ No newline at end of file diff --git a/odorobo-scheduler/src/bin/odorobo-scheduler.rs b/odorobo-scheduler/src/bin/odorobo-scheduler.rs index ad0b614..62746b6 100644 --- a/odorobo-scheduler/src/bin/odorobo-scheduler.rs +++ b/odorobo-scheduler/src/bin/odorobo-scheduler.rs @@ -1,11 +1,13 @@ use kameo::prelude::*; -use odorobo_shared::utils::DynError; +use stable_eyre::Result; use odorobo_scheduler::scheduler_actor::SchedulerActor; use odorobo_shared::connect_to_swarm; #[tokio::main] -async fn main() -> Result<(), DynError> { +async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; + + odorobo_shared::utils::init()?; let actor_ref = SchedulerActor::spawn(SchedulerActor {}); actor_ref.register("scheduler").await?; diff --git a/odorobo-scheduler/src/scheduler_actor.rs b/odorobo-scheduler/src/scheduler_actor.rs index 5eb85bb..0064427 100644 --- a/odorobo-scheduler/src/scheduler_actor.rs +++ b/odorobo-scheduler/src/scheduler_actor.rs @@ -1,246 +1,73 @@ -use axum::{Json, Router}; use axum::extract::State; use axum::http::StatusCode; use axum::routing::{get, post}; +use axum::{Json, Router}; use kameo::prelude::*; +use libp2p::PeerId; use libp2p::futures::TryStreamExt; +use odorobo_shared::messages::server_status::GetServerStatus; use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use libp2p::PeerId; use utoipa::OpenApi; -use odorobo_shared::kameo_messages::{ServerStatus, GetServerStatus}; +use uuid::Uuid; //use odorobo_shared::odorobo::server_actor::ServerActor; -use odorobo_shared::utils::DynError; +use odorobo_agent::actor::AgentActor; +use odorobo_shared::messages::create_vm::*; +use odorobo_shared::messages::debug::PanicAgent; +use stable_eyre::{Report, Result}; #[derive(RemoteActor)] -pub struct SchedulerActor { } +pub struct SchedulerActor {} const PING_RETURN_VALUE: &str = "pong"; const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: make this based on EXTERNAL_HTTP_ADDRESS. const compile time stuff is a pain. - impl Actor for SchedulerActor { type Args = Self; - type Error = DynError; + type Error = Report; async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { - let axum_router = Router::new() - .route("/ping", get(|| async { PING_RETURN_VALUE })) - .route("/create_vm", post(create_vm)) - .route("/delete_vm", post(delete_vm)) - .route("/update_vm", post(update_vm)) - .route("/get_vm", post(get_vm)) - .route("/drain_server", post(drain_server)) - .route("/get_servers", post(get_servers)) - .with_state(actor_ref); - - - println!("starting axum server at {}", EXTERNAL_HTTP_URL); + let peer_id = actor_ref.id().peer_id().unwrap().clone(); - // run our app with hyper, listening globally on port 3000 - tokio::spawn(async move { - let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS).await.unwrap(); - axum::serve(listener, axum_router).await.unwrap(); - }); + println!("Actor started! Scheduler peer id: {peer_id}"); - // spin loop until the axum server starts responding to requests - // TODO: if anyone has a better way to detect the axum server is up, change it to that. + let mut agent_actor: Option> = None; - let mut count = 0; loop { - count += 1; - println!("attempting to hit axum server, attempt {}", count); - - let resp_result: Result<(), DynError> = async { - let resp = reqwest::get(EXTERNAL_HTTP_URL.to_owned() + "/ping") - .await? - .text() - .await?; - - if resp != PING_RETURN_VALUE { - return Err("invalid ping response".into()); - } - - Ok(()) - }.await; - - match resp_result { - Ok(()) => { - break; - }, - Err(e) => { - println!("{}", e) - } - } - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } + let agent_actor_option = RemoteActorRef::::lookup("agent").await?; - println!("Actor started"); - Ok(state) - } -} + let Some(agent_actor_in_loop) = agent_actor_option else { + continue; + }; -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct CreateVM { - pub uuid: Uuid, - pub name: String, - pub vcpus: u32, - pub ram: u32, - pub image: String, -} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GenericSuccessResponse { - pub success: bool, -} - -// no response. just use status code 200 vs not 200 for if it worked. -#[utoipa::path( - post, - path = "/create_vm", - request_body(content = CreateVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -pub async fn create_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - todo!() -} + agent_actor = Some(agent_actor_in_loop); + break; + } -pub type UpdateVM = CreateVM; - -#[utoipa::path( - post, - path = "/update_vm", - request_body(content = UpdateVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn update_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - todo!() -} + let agent_actor = agent_actor.unwrap(); + let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); -pub type DeleteVM = GetVM; -#[utoipa::path( - post, - path = "/delete_vm", - request_body(content = DeleteVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn delete_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - todo!() -} + println!("Agent actor peer id: {agent_actor_peer_id}"); + let reply = agent_actor + .ask(&CreateVM { + vm_id: Default::default(), + config: Default::default(), + }) + .await?; -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetVM { - pub uuid: Uuid, -} + println!("Created VM Reply: {:?}", reply); -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetVMResponse { - pub cpus: bool -} -#[utoipa::path( - post, - path = "/get_vm", - request_body(content = GetVM, content_type = "application/json"), - responses( - (status = 200, body = GetVMResponse) - ) -)] -async fn get_vm(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - todo!() -} + tokio::time::sleep(std::time::Duration::from_secs(10)).await; -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct DrainServer {} - -#[utoipa::path( - post, - path = "/get_vm", - request_body(content = DrainServer, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn drain_server(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - todo!() -} + println!("Panicking Agent."); -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetServers { - pub start_index: u64, - pub end_index: u64 -} + agent_actor.tell(&PanicAgent).send()?; -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetServersResponse { - pub total_servers: u64, - pub servers: Vec, -} + println!("Agent has been panicked."); - -#[utoipa::path( - post, - path = "/get_servers", - request_body(content = GetServers, content_type = "application/json"), - responses( - (status = 200, body = GetServersResponse) - ) -)] -async fn get_servers(State(state): State>, Json(payload): Json) -> (StatusCode, String) { - /* - let mut servers: Vec<(PeerId, ServerStatus)> = Vec::new(); - - let server_actor_response: Result<(), DynError> = async { - println!("getting server actors"); - - let mut server_actors = RemoteActorRef::::lookup_all("server"); - - while let Some(server_actor) = server_actors.try_next().await? { - // Send message to each instance - println!("asking {:?}", server_actor); - let result = server_actor.ask(&GetServerStatus { }).await?; - println!("result {:?}", result); - - if let Some(peerId) = server_actor.id().peer_id() { - servers.push((*peerId, result)); - } - } - - Ok(()) - }.await; - - match server_actor_response { - Ok(()) => { - (StatusCode::OK, serde_json::to_string(&servers).unwrap()) - }, - _ => { - (StatusCode::INTERNAL_SERVER_ERROR, "".parse().unwrap()) - } + Ok(state) } - - */ - todo!() } - -pub fn gen_openapi_spec() -> String { - #[derive(OpenApi)] - #[openapi( - components( - schemas(CreateVM, UpdateVM, DeleteVM, GetVM, DrainServer, GetServers, GenericSuccessResponse, GetVMResponse, GetServersResponse) - ), - paths(get_servers) - )] - struct ApiDoc; - - ApiDoc::openapi().to_pretty_json().unwrap() -} \ No newline at end of file diff --git a/odorobo-scheduler/src/scheduler_actor_http.rs b/odorobo-scheduler/src/scheduler_actor_http.rs new file mode 100644 index 0000000..03d01c8 --- /dev/null +++ b/odorobo-scheduler/src/scheduler_actor_http.rs @@ -0,0 +1,310 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use kameo::prelude::*; +use libp2p::PeerId; +use libp2p::futures::TryStreamExt; +use odorobo_shared::messages::server_status::{GetServerStatus}; +use serde::{Deserialize, Serialize}; +use utoipa::OpenApi; +use uuid::Uuid; +//use odorobo_shared::odorobo::server_actor::ServerActor; +use odorobo_agent::actor::AgentActor; +use odorobo_shared::messages::create_vm::*; +use stable_eyre::{Report, Result}; + +#[derive(RemoteActor)] +pub struct SchedulerActor {} + +const PING_RETURN_VALUE: &str = "pong"; +const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; + +const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: make this based on EXTERNAL_HTTP_ADDRESS. const compile time stuff is a pain. + +impl Actor for SchedulerActor { + type Args = Self; + type Error = Report; + + async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + on_start_debug(state, actor_ref).await + + //on_start_normal(state, actor_ref).await + + //Ok(state) + } +} + +async fn on_start_debug( + state: SchedulerActor, + actor_ref: ActorRef, +) -> Result { + let peer_id = actor_ref.id().peer_id().unwrap().clone(); + + println!("Actor started! Scheduler peer id:{peer_id}"); + + let agent_actor = RemoteActorRef::::lookup("agent") + .await? + .unwrap(); + + let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); + + println!("Agent actor peer id: {agent_actor_peer_id}"); + + agent_actor.ask(&CreateVM { + vm_id: Default::default(), + config: Default::default(), + }); + + Ok(state) +} + +async fn on_start_normal( + state: SchedulerActor, + actor_ref: ActorRef, +) -> Result { + let axum_router = Router::new() + .route("/ping", get(|| async { PING_RETURN_VALUE })) + .route("/create_vm", post(create_vm)) + .route("/delete_vm", post(delete_vm)) + .route("/update_vm", post(update_vm)) + .route("/get_vm", post(get_vm)) + .route("/create_volume", post(create_vm)) + .route("/delete_volume", post(delete_vm)) + .route("/update_volume", post(update_vm)) + .route("/get_volume", post(get_vm)) + .route("/drain_server", post(drain_server)) + // .route("/get_servers", post(get_servers)) + .with_state(actor_ref); + + println!("starting axum server at {}", EXTERNAL_HTTP_URL); + + // run our app with hyper, listening globally on port 3000 + tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) + .await + .unwrap(); + axum::serve(listener, axum_router).await.unwrap(); + }); + + // spin loop until the axum server starts responding to requests + // TODO: if anyone has a better way to detect the axum server is up, change it to that. + + let mut count = 0; + loop { + count += 1; + println!("attempting to hit axum server, attempt {}", count); + + let resp_result: Result<()> = async { + let resp = reqwest::get(EXTERNAL_HTTP_URL.to_owned() + "/ping") + .await? + .text() + .await?; + + if resp != PING_RETURN_VALUE { + return Err(stable_eyre::eyre::eyre!("invalid ping response")); + } + + Ok(()) + } + .await; + + match resp_result { + Ok(()) => { + break; + } + Err(e) => { + println!("{}", e) + } + } + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + println!("Actor started"); + Ok(state) +} +/* +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct CreateVM { + pub uuid: Uuid, + pub name: String, + pub vcpus: u32, + pub ram: u32, + pub image: String, +} +*/ + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GenericSuccessResponse { + pub success: bool, +} + +// no response. just use status code 200 vs not 200 for if it worked. +#[utoipa::path( + post, + path = "/create_vm", + request_body(content = CreateVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +pub async fn create_vm( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + todo!() +} + +pub type UpdateVM = CreateVM; + +#[utoipa::path( + post, + path = "/update_vm", + request_body(content = UpdateVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn update_vm( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + todo!() +} + +pub type DeleteVM = GetVM; +#[utoipa::path( + post, + path = "/delete_vm", + request_body(content = DeleteVM, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn delete_vm( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + todo!() +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetVM { + pub uuid: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetVMResponse { + pub cpus: bool, +} +#[utoipa::path( + post, + path = "/get_vm", + request_body(content = GetVM, content_type = "application/json"), + responses( + (status = 200, body = GetVMResponse) + ) +)] +async fn get_vm( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + todo!() +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct DrainServer {} + +#[utoipa::path( + post, + path = "/get_vm", + request_body(content = DrainServer, content_type = "application/json"), + responses( + (status = 200, body = GenericSuccessResponse) + ) +)] +async fn drain_server( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + todo!() +} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetServers { + pub start_index: u64, + pub end_index: u64, +} + +// this is a debug thing to make utoipa happy because we havent remove it. +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct ServerStatus {} + +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct GetServersResponse { + pub total_servers: u64, + pub servers: Vec, +} +/* +// this again is commented out because it isnt not fully working and we need this for debug reasons to test panic stuff. +#[utoipa::path( + post, + path = "/get_servers", + request_body(content = GetServers, content_type = "application/json"), + responses( + (status = 200, body = GetServersResponse) + ) +)] +async fn get_servers( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, String) { + let mut servers: Vec<(PeerId, ServerStatus)> = Vec::new(); + + let server_actor_response: Result<()> = async { + println!("getting server actors"); + + let mut server_actors = RemoteActorRef::::lookup_all("agent"); + + while let Some(server_actor) = server_actors.try_next().await? { + // Send message to each instance + println!("asking {:?}", server_actor); + let result = server_actor.ask(&GetServerStatus {}).await?; + println!("result {:?}", result); + + if let Some(peerId) = server_actor.id().peer_id() { + servers.push((*peerId, result)); + } + } + + Ok(()) + } + .await; + + match server_actor_response { + Ok(()) => (StatusCode::OK, serde_json::to_string(&servers).unwrap()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "".parse().unwrap()), + } +} +*/ +pub fn gen_openapi_spec() -> String { + #[derive(OpenApi)] + #[openapi( + components(schemas( + CreateVM, + UpdateVM, + DeleteVM, + GetVM, + DrainServer, + // GetServers, + GenericSuccessResponse, + GetVMResponse, + GetServersResponse + )), + // paths(get_servers) + )] + struct ApiDoc; + + ApiDoc::openapi().to_pretty_json().unwrap() +} diff --git a/odorobo-shared/Cargo.toml b/odorobo-shared/Cargo.toml index a0672b8..080a725 100644 --- a/odorobo-shared/Cargo.toml +++ b/odorobo-shared/Cargo.toml @@ -5,8 +5,14 @@ edition = "2024" license = "AGPL-3.0-or-later" [dependencies] -kameo = { version = "0.19.2", features = ["remote"] } -tokio = { version = "1.50.0", features = ["full"] } -libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } -serde = { version = "1.0.228", features = ["derive"] } +kameo = { workspace = true, features = ["remote"] } +tokio = { workspace = true, features = ["full"] } +libp2p = { workspace = true, features = ["yamux", "serde", "mdns", "kad"] } +serde = { workspace = true, features = ["derive"] } utoipa = { version = "5.4.0", features=["uuid"] } +stable-eyre = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } +cloud-hypervisor-client = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tracing = { workspace = true } \ No newline at end of file diff --git a/odorobo-shared/src/error.rs b/odorobo-shared/src/error.rs new file mode 100644 index 0000000..890a1c4 --- /dev/null +++ b/odorobo-shared/src/error.rs @@ -0,0 +1,8 @@ + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum OdoroboError { + #[error("")] + Foobar +} diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index af9f1c4..a824726 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -1,22 +1,27 @@ pub mod utils; -pub mod kameo_messages; - +pub mod messages; +pub mod error; use kameo::prelude::*; -use libp2p::{mdns, noise, tcp, yamux, PeerId}; +use libp2p::kad::Record; +use libp2p::{mdns, noise, tcp, yamux, PeerId, kad}; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; -use crate::utils::DynError; +use stable_eyre::Result; +use libp2p::bytes::BufMut; +use std::cell::RefCell; + #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { kameo: remote::Behaviour, mdns: mdns::tokio::Behaviour, + kad: kad::Behaviour, } // based on: // https://github.com/tqwewe/kameo/blob/main/examples/custom_swarm.rs // https://docs.page/tqwewe/kameo/distributed-actors/custom-swarm-configuration -pub fn connect_to_swarm() -> Result { +pub fn connect_to_swarm() -> Result { let mut swarm = libp2p::SwarmBuilder::with_new_identity() .with_tokio() .with_tcp(tcp::Config::default(), noise::Config::new, yamux::Config::default)? @@ -27,11 +32,31 @@ pub fn connect_to_swarm() -> Result { local_peer_id, remote::messaging::Config::default(), ); + + let mut kad = kad::Behaviour::with_config( + local_peer_id, + kad::store::MemoryStore::new(local_peer_id), + kad::Config::default(), + ); + + kad.bootstrap()?; let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)?; - Ok(ProductionBehaviour { kameo, mdns }) + Ok(ProductionBehaviour { kameo, mdns, kad }) })? - .build(); + .build(); + + let mut pk_record_key = vec![]; + pk_record_key.put_slice("/pk/".as_bytes()); + pk_record_key.put_slice(swarm.local_peer_id().to_bytes().as_slice()); + + let record = kad::Record::new(pk_record_key, "test2".into()); + + + let behavior = swarm.behaviour_mut(); + + behavior.kad.put_record(record, kad::Quorum::Majority)?; + // Initialize Kameo's global registry swarm.behaviour().kameo.init_global(); diff --git a/odorobo-shared/src/messages/create_vm.rs b/odorobo-shared/src/messages/create_vm.rs new file mode 100644 index 0000000..adab0ed --- /dev/null +++ b/odorobo-shared/src/messages/create_vm.rs @@ -0,0 +1,30 @@ +use cloud_hypervisor_client::models::VmConfig; +use kameo::Reply; +use kameo::prelude::*; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +// TODO: when scheduler does createVM it also stores which server we put the Ulid on so it can do a in memory cache and doesn't need to hit the Server +// for failover, the new node when it fails over will need to rebuild this cache via hitting a GetAllVMs message on every server +// additionally, when the VmConfig is created, this determines the MAC address of the server. meaning as soon as we have this info, we need to hit the router via the scheduler, because the router might be slow. +/// Message to create a new VM +/// +/// VmConfig is a Cloud Hypervisor VM spec, containing the VM's full configuration (untransformed by odorobo) + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateVM { + /// the ULID of the VM to create + pub vm_id: Ulid, + /// VmConfig in message, untransformed. + /// + /// Transformer API will transform this VmConfig into proper + /// node-specific, paths, i.e attach LUNs, networking? + /// + /// this data would go to state::instance::spawn() + pub config: VmConfig, +} + +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct CreateVMReply { + pub config: Option, +} diff --git a/odorobo-shared/src/messages/debug.rs b/odorobo-shared/src/messages/debug.rs new file mode 100644 index 0000000..fb54cde --- /dev/null +++ b/odorobo-shared/src/messages/debug.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +/// Forcibly panics the agent +/// +/// Used for debugging purposes. +/// +/// This should not be used in production. +#[derive(Serialize, Deserialize, Debug)] +pub struct PanicAgent; diff --git a/odorobo-shared/src/messages/mod.rs b/odorobo-shared/src/messages/mod.rs new file mode 100644 index 0000000..da6892a --- /dev/null +++ b/odorobo-shared/src/messages/mod.rs @@ -0,0 +1,3 @@ +pub mod create_vm; +pub mod debug; +pub mod server_status; diff --git a/odorobo-shared/src/kameo_messages.rs b/odorobo-shared/src/messages/server_status.rs similarity index 50% rename from odorobo-shared/src/kameo_messages.rs rename to odorobo-shared/src/messages/server_status.rs index 0e704bc..fa61286 100644 --- a/odorobo-shared/src/kameo_messages.rs +++ b/odorobo-shared/src/messages/server_status.rs @@ -1,12 +1,10 @@ -use std::fmt::{Display, Formatter}; -use kameo::Reply; -use libp2p::PeerId; use serde::{Deserialize, Serialize}; +use kameo::Reply; #[derive(Serialize, Deserialize)] -pub struct GetServerStatus {} +pub struct GetServerStatus; -#[derive(Serialize, Deserialize, Reply, Debug, utoipa::ToSchema)] +#[derive(Serialize, Deserialize, Reply, Debug)] pub struct ServerStatus { pub vcpus: u32, pub ram: u32 diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index 96a7fe3..1044c88 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -1 +1,27 @@ -pub type DynError = Box; \ No newline at end of file +use stable_eyre::Result; +// use tracing:: +use tracing_subscriber::{EnvFilter, filter::LevelFilter, fmt, prelude::*}; + +pub fn env_filter() -> EnvFilter { + let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); + + let base = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse_lossy(&env); + + #[cfg(debug_assertions)] + let base = base.add_directive("odorobo*=trace".parse().unwrap()); + + base +} + +pub fn init() -> Result<()> { + stable_eyre::install()?; + tracing_subscriber::fmt() + .with_env_filter(env_filter()) + .with_file(true) + .with_line_number(true) + .init(); + + Ok(()) +} From d80654be2c4f1639d25dff5a56cc67cdcd2fafa4 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 6 Apr 2026 00:58:54 -0500 Subject: [PATCH 057/189] switch to just mdns cause we need something for peer discovery, but if we can find another option we should use it instead --- odorobo-scheduler/src/scheduler_actor.rs | 3 ++- odorobo-shared/src/lib.rs | 25 ++---------------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/odorobo-scheduler/src/scheduler_actor.rs b/odorobo-scheduler/src/scheduler_actor.rs index 0064427..c5927c9 100644 --- a/odorobo-scheduler/src/scheduler_actor.rs +++ b/odorobo-scheduler/src/scheduler_actor.rs @@ -7,6 +7,7 @@ use libp2p::PeerId; use libp2p::futures::TryStreamExt; use odorobo_shared::messages::server_status::GetServerStatus; use serde::{Deserialize, Serialize}; +use tracing::info; use utoipa::OpenApi; use uuid::Uuid; //use odorobo_shared::odorobo::server_actor::ServerActor; @@ -58,7 +59,7 @@ impl Actor for SchedulerActor { }) .await?; - println!("Created VM Reply: {:?}", reply); + info!("Created VM Reply: {:?}", reply); tokio::time::sleep(std::time::Duration::from_secs(10)).await; diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index a824726..7990192 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -15,7 +15,6 @@ use std::cell::RefCell; pub struct ProductionBehaviour { kameo: remote::Behaviour, mdns: mdns::tokio::Behaviour, - kad: kad::Behaviour, } // based on: @@ -31,32 +30,12 @@ pub fn connect_to_swarm() -> Result { let kameo = remote::Behaviour::new( local_peer_id, remote::messaging::Config::default(), - ); - - let mut kad = kad::Behaviour::with_config( - local_peer_id, - kad::store::MemoryStore::new(local_peer_id), - kad::Config::default(), - ); - - kad.bootstrap()?; - + ); let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)?; - Ok(ProductionBehaviour { kameo, mdns, kad }) + Ok(ProductionBehaviour { kameo, mdns }) })? .build(); - let mut pk_record_key = vec![]; - pk_record_key.put_slice("/pk/".as_bytes()); - pk_record_key.put_slice(swarm.local_peer_id().to_bytes().as_slice()); - - let record = kad::Record::new(pk_record_key, "test2".into()); - - - let behavior = swarm.behaviour_mut(); - - behavior.kad.put_record(record, kad::Quorum::Majority)?; - // Initialize Kameo's global registry swarm.behaviour().kameo.init_global(); From 91e05b382d969810150f3a8bb6ca10d25aa018ed Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 6 Apr 2026 01:29:36 -0500 Subject: [PATCH 058/189] kademlia dht notes. --- docs/kademlia_dht.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/kademlia_dht.md b/docs/kademlia_dht.md index 3c1a7f0..2d872c3 100644 --- a/docs/kademlia_dht.md +++ b/docs/kademlia_dht.md @@ -1,6 +1,12 @@ # kademlia dht -the cluster uses a dht for node lookups and for any global state we store +the cluster can use a dht for node lookups and for any global state we store. + +we should have someone (probably caleb) look at it for like an hour or two because if it works it could be possibly faster, definitely more reliable, and easier then a centralized solution like postgres. if they cant get it working in 2h, just stop working on it entirely and it becomes a post MVP thing. + +To do this, basically implement what is in the following example into the connect_to_swarm function: https://github.com/libp2p/rust-libp2p/blob/master/examples/ipfs-kad/src/main.rs. + +One problem with this is to bootstrap, we either also need mdns for the first nodes, or to hardcode a couple nodes. ### quorum/replication factor @@ -10,4 +16,8 @@ basically if a server goes down and we only run dht actors/nodes on the compute This means we need to make sure that our replication factor is high enough that the percent chance of any key in the dht not losing majority quorum -this will be less of a problem as we have more servers that can run dht nodes. one thing that may help is running some dht nodes on the \ No newline at end of file +this will be less of a problem as we have more servers that can run dht nodes. one thing that may help is running some dht nodes on the storage nodes. + +The spec recommends a replication factor of 20. https://github.com/libp2p/specs/blob/master/kad-dht/README.md#replication-parameter-k + +That is likely high enough that we might not even need to do the math. From fe61ac884093e61bd106bc19f807eddbea8133a8 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 6 Apr 2026 01:38:06 -0500 Subject: [PATCH 059/189] add release-optimized cargo profile --- Cargo.toml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 955357b..405e6be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,26 @@ odorobo-shared = { path = "odorobo-shared" } odorobo-agent = { path = "odorobo-agent" } odoroboctl = { path = "odoroboctl" } odorobo-scheduler = { path = "odorobo-scheduler" } + + +[profile.release-optimized] +# these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level +# these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. + +# you should also try to setup rustflags -C target-cpu=native, but this only works on the target machine. +# this enables microarchitecture optimizations, which is just better if we can. +# You can also set the target-cpu to do cross compilation if you know the exact micro architecture of the target machine. + +inherits = "release" + +opt-level = 3 +lto = "fat" +codegen-units = 1 + +# we are specifically NOT using panic = "abort" because we need to use catch_unwind for kameo actors on_panic. +# for any crates using kameo actors on_panic, this would literally break the binary. +# For specific crates/binaries this might be something we do, but I doubt it. + +strip = "symbols" # TODO: this we should talk about doing this, because it makes debugging harder, so if we have a panic in production, the backtrace will likely not be as useful. + +incremental = false # this is just to get reproducible builds. incremental compiles aren't consistent and can sometimes be broken when combined with optimizations \ No newline at end of file From b2f79c3bf8024280255af2ad2298394e81408bc7 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 6 Apr 2026 01:40:36 -0500 Subject: [PATCH 060/189] slight doc change for profile release-optimized --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 405e6be..8d3eb11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ codegen-units = 1 # we are specifically NOT using panic = "abort" because we need to use catch_unwind for kameo actors on_panic. # for any crates using kameo actors on_panic, this would literally break the binary. -# For specific crates/binaries this might be something we do, but I doubt it. +# For specific crates/binaries this might be something we do, but I (caleb) doubt it. strip = "symbols" # TODO: this we should talk about doing this, because it makes debugging harder, so if we have a panic in production, the backtrace will likely not be as useful. From b977e879d14b9f6dc9918e3b442a725bf825a811 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 7 Apr 2026 16:19:11 +0700 Subject: [PATCH 061/189] refactor: rename to manager --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- {odorobo-scheduler => odorobo-manager}/Cargo.toml | 5 +---- {odorobo-scheduler => odorobo-manager}/src/lib.rs | 0 .../odorobo-scheduler.rs => odorobo-manager/src/main.rs | 8 ++++---- .../src/scheduler_actor.rs | 0 .../src/scheduler_actor_http.rs | 0 7 files changed, 8 insertions(+), 11 deletions(-) rename {odorobo-scheduler => odorobo-manager}/Cargo.toml (94%) rename {odorobo-scheduler => odorobo-manager}/src/lib.rs (100%) rename odorobo-scheduler/src/bin/odorobo-scheduler.rs => odorobo-manager/src/main.rs (85%) rename {odorobo-scheduler => odorobo-manager}/src/scheduler_actor.rs (100%) rename {odorobo-scheduler => odorobo-manager}/src/scheduler_actor_http.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index b9d7b2c..6f094fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2896,7 +2896,7 @@ dependencies = [ ] [[package]] -name = "odorobo-scheduler" +name = "odorobo-manager" version = "0.1.0" dependencies = [ "ahash", diff --git a/Cargo.toml b/Cargo.toml index 8d3eb11..bf2a168 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["odorobo-agent", "odoroboctl", "odorobo-scheduler", "odorobo-shared"] +members = ["odorobo-agent", "odoroboctl", "odorobo-manager", "odorobo-shared"] [workspace.dependencies] stable-eyre = "0.2.2" @@ -26,7 +26,7 @@ ulid = { version = "1.2", features = ["serde"] } odorobo-shared = { path = "odorobo-shared" } odorobo-agent = { path = "odorobo-agent" } odoroboctl = { path = "odoroboctl" } -odorobo-scheduler = { path = "odorobo-scheduler" } +odorobo-manager = { path = "odorobo-manager" } [profile.release-optimized] diff --git a/odorobo-scheduler/Cargo.toml b/odorobo-manager/Cargo.toml similarity index 94% rename from odorobo-scheduler/Cargo.toml rename to odorobo-manager/Cargo.toml index 702cca0..2046ce7 100644 --- a/odorobo-scheduler/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -1,11 +1,8 @@ [package] -name = "odorobo-scheduler" +name = "odorobo-manager" version = "0.1.0" edition = "2024" -[[bin]] -name = "odorobo-scheduler" - [dependencies] kameo = { version = "0.19.2", features = ["remote"] } axum = { version = "0.8.8", features = ["tracing", "macros"] } diff --git a/odorobo-scheduler/src/lib.rs b/odorobo-manager/src/lib.rs similarity index 100% rename from odorobo-scheduler/src/lib.rs rename to odorobo-manager/src/lib.rs diff --git a/odorobo-scheduler/src/bin/odorobo-scheduler.rs b/odorobo-manager/src/main.rs similarity index 85% rename from odorobo-scheduler/src/bin/odorobo-scheduler.rs rename to odorobo-manager/src/main.rs index 62746b6..73409fa 100644 --- a/odorobo-scheduler/src/bin/odorobo-scheduler.rs +++ b/odorobo-manager/src/main.rs @@ -1,12 +1,12 @@ use kameo::prelude::*; -use stable_eyre::Result; -use odorobo_scheduler::scheduler_actor::SchedulerActor; +use odorobo_manager::scheduler_actor::SchedulerActor; use odorobo_shared::connect_to_swarm; +use stable_eyre::Result; #[tokio::main] async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; - + odorobo_shared::utils::init()?; let actor_ref = SchedulerActor::spawn(SchedulerActor {}); @@ -15,4 +15,4 @@ async fn main() -> Result<()> { actor_ref.wait_for_shutdown().await; Ok(()) -} \ No newline at end of file +} diff --git a/odorobo-scheduler/src/scheduler_actor.rs b/odorobo-manager/src/scheduler_actor.rs similarity index 100% rename from odorobo-scheduler/src/scheduler_actor.rs rename to odorobo-manager/src/scheduler_actor.rs diff --git a/odorobo-scheduler/src/scheduler_actor_http.rs b/odorobo-manager/src/scheduler_actor_http.rs similarity index 100% rename from odorobo-scheduler/src/scheduler_actor_http.rs rename to odorobo-manager/src/scheduler_actor_http.rs From 4225c2a905bcc4b4af4434f0c0512140bb3f5942 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 7 Apr 2026 16:27:39 +0700 Subject: [PATCH 062/189] clean up some code, use tracing instead of raw log --- odorobo-agent/src/actor/mod.rs | 21 +++++++++++---------- odorobo-manager/src/main.rs | 2 ++ odorobo-manager/src/scheduler_actor.rs | 12 ++++++------ odorobo-shared/src/lib.rs | 17 +++++++++-------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 340d313..8cdb699 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -1,15 +1,16 @@ use crate::state::provisioning::actor::VMActor; use ahash::AHashMap; use bytesize::ByteSize; +use kameo::error::ActorStopReason; use kameo::prelude::*; use odorobo_shared::messages::create_vm::*; use odorobo_shared::messages::debug::PanicAgent; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; +use tracing::error; +use std::ops::ControlFlow; use std::{fs, path::PathBuf}; use sysinfo::System; -use std::ops::ControlFlow; -use kameo::error::ActorStopReason; use kameo::error::PanicError; @@ -75,16 +76,16 @@ impl Actor for AgentActor { // async fn on_panic(state: Self::Args, weak_actor_ref: WeakActorRef, _panic: &PanicError) { // panic!("Agent panicked: {:?}", _panic); // } - // + // async fn on_panic( - &mut self, - actor_ref: WeakActorRef, - err: PanicError, - ) -> Result> { - println!("Agent panicked: {:?}", err); - + &mut self, + actor_ref: WeakActorRef, + err: PanicError, + ) -> Result> { + error!("Agent panicked: {:?}", err); + // todo: if we panic, we should completely regen the self struct from scratch. The assumption should be that memory corruption could have possibly happened becauew - + Ok(ControlFlow::Continue(())) } } diff --git a/odorobo-manager/src/main.rs b/odorobo-manager/src/main.rs index 73409fa..7ac2a47 100644 --- a/odorobo-manager/src/main.rs +++ b/odorobo-manager/src/main.rs @@ -2,12 +2,14 @@ use kameo::prelude::*; use odorobo_manager::scheduler_actor::SchedulerActor; use odorobo_shared::connect_to_swarm; use stable_eyre::Result; +use tracing::info; #[tokio::main] async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; odorobo_shared::utils::init()?; + info!("Starting odorobo-manager"); let actor_ref = SchedulerActor::spawn(SchedulerActor {}); actor_ref.register("scheduler").await?; diff --git a/odorobo-manager/src/scheduler_actor.rs b/odorobo-manager/src/scheduler_actor.rs index c5927c9..4386ca8 100644 --- a/odorobo-manager/src/scheduler_actor.rs +++ b/odorobo-manager/src/scheduler_actor.rs @@ -7,7 +7,7 @@ use libp2p::PeerId; use libp2p::futures::TryStreamExt; use odorobo_shared::messages::server_status::GetServerStatus; use serde::{Deserialize, Serialize}; -use tracing::info; +use tracing::{error, info, warn}; use utoipa::OpenApi; use uuid::Uuid; //use odorobo_shared::odorobo::server_actor::ServerActor; @@ -31,7 +31,7 @@ impl Actor for SchedulerActor { async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { let peer_id = actor_ref.id().peer_id().unwrap().clone(); - println!("Actor started! Scheduler peer id: {peer_id}"); + info!("Actor started! Scheduler peer id: {peer_id}"); let mut agent_actor: Option> = None; @@ -50,7 +50,7 @@ impl Actor for SchedulerActor { let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); - println!("Agent actor peer id: {agent_actor_peer_id}"); + info!("Agent actor peer id: {agent_actor_peer_id}"); let reply = agent_actor .ask(&CreateVM { @@ -59,15 +59,15 @@ impl Actor for SchedulerActor { }) .await?; - info!("Created VM Reply: {:?}", reply); + info!(?reply, "Created VM Reply"); tokio::time::sleep(std::time::Duration::from_secs(10)).await; - println!("Panicking Agent."); + warn!("Panicking Agent"); agent_actor.tell(&PanicAgent).send()?; - println!("Agent has been panicked."); + error!("Agent has been panicked."); Ok(state) } diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index 7990192..456ae65 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -8,6 +8,7 @@ use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use stable_eyre::Result; use libp2p::bytes::BufMut; +use tracing::{debug, info, warn}; use std::cell::RefCell; @@ -45,7 +46,7 @@ pub fn connect_to_swarm() -> Result { let local_peer_id = *swarm.local_peer_id(); - println!("Local peer id: {:?}", local_peer_id); + info!("Local peer id: {:?}", local_peer_id); // Spawn the swarm task tokio::spawn(async move { @@ -54,13 +55,13 @@ pub fn connect_to_swarm() -> Result { // Handle mDNS discovery SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Discovered(list))) => { for (peer_id, multiaddr) in list { - println!("mDNS discovered peer: {peer_id}"); + info!("mDNS discovered peer: {peer_id}"); swarm.add_peer_address(peer_id, multiaddr); } } SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Expired(list))) => { for (peer_id, _) in list { - println!("mDNS peer expired: {peer_id}"); + warn!("mDNS peer expired: {peer_id}"); let _ = swarm.disconnect_peer_id(peer_id); } } @@ -68,22 +69,22 @@ pub fn connect_to_swarm() -> Result { SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Registry( registry_event, ))) => { - println!("Registry event: {:?}", registry_event); + debug!("Registry event: {:?}", registry_event); } SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Messaging( messaging_event, ))) => { - println!("Messaging event: {:?}", messaging_event); + debug!("Messaging event: {:?}", messaging_event); } // Handle other swarm events SwarmEvent::NewListenAddr { address, .. } => { - println!("Listening on {address}"); + info!("Listening on {address}"); } SwarmEvent::ConnectionEstablished { peer_id, .. } => { - println!("Connected to {peer_id}"); + info!("Connected to {peer_id}"); } SwarmEvent::ConnectionClosed { peer_id, cause, .. } => { - println!("Disconnected from {peer_id}: {cause:?}"); + info!("Disconnected from {peer_id}: {cause:?}"); } _ => {} } From b1cda6756aeb1f639fee81e50c03b0212fc9ad29 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 7 Apr 2026 16:43:19 +0700 Subject: [PATCH 063/189] clean up log messages --- odorobo-shared/src/lib.rs | 55 +++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index 456ae65..861e234 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -1,16 +1,15 @@ -pub mod utils; -pub mod messages; pub mod error; +pub mod messages; +pub mod utils; use kameo::prelude::*; -use libp2p::kad::Record; -use libp2p::{mdns, noise, tcp, yamux, PeerId, kad}; +use libp2p::bytes::BufMut; use libp2p::futures::StreamExt; +use libp2p::kad::Record; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; +use libp2p::{PeerId, kad, mdns, noise, tcp, yamux}; use stable_eyre::Result; -use libp2p::bytes::BufMut; -use tracing::{debug, info, warn}; use std::cell::RefCell; - +use tracing::{debug, info, warn}; #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { @@ -24,19 +23,19 @@ pub struct ProductionBehaviour { pub fn connect_to_swarm() -> Result { let mut swarm = libp2p::SwarmBuilder::with_new_identity() .with_tokio() - .with_tcp(tcp::Config::default(), noise::Config::new, yamux::Config::default)? + .with_tcp( + tcp::Config::default(), + noise::Config::new, + yamux::Config::default, + )? .with_behaviour(|key| { let local_peer_id = key.public().to_peer_id(); - let kameo = remote::Behaviour::new( - local_peer_id, - remote::messaging::Config::default(), - ); + let kameo = remote::Behaviour::new(local_peer_id, remote::messaging::Config::default()); let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id)?; Ok(ProductionBehaviour { kameo, mdns }) })? - .build(); - + .build(); // Initialize Kameo's global registry swarm.behaviour().kameo.init_global(); @@ -53,32 +52,36 @@ pub fn connect_to_swarm() -> Result { loop { match swarm.select_next_some().await { // Handle mDNS discovery - SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Discovered(list))) => { + SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Discovered( + list, + ))) => { for (peer_id, multiaddr) in list { info!("mDNS discovered peer: {peer_id}"); swarm.add_peer_address(peer_id, multiaddr); } } - SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Expired(list))) => { + SwarmEvent::Behaviour(ProductionBehaviourEvent::Mdns(mdns::Event::Expired( + list, + ))) => { for (peer_id, _) in list { warn!("mDNS peer expired: {peer_id}"); let _ = swarm.disconnect_peer_id(peer_id); } } // Handle Kameo events (optional - for monitoring) - SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Registry( - registry_event, - ))) => { - debug!("Registry event: {:?}", registry_event); + SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo( + remote::Event::Registry(registry_event), + )) => { + debug!(?registry_event, "Registry event"); } - SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo(remote::Event::Messaging( - messaging_event, - ))) => { - debug!("Messaging event: {:?}", messaging_event); + SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo( + remote::Event::Messaging(messaging_event), + )) => { + debug!(?messaging_event, "Messaging event"); } // Handle other swarm events SwarmEvent::NewListenAddr { address, .. } => { - info!("Listening on {address}"); + info!(?address, "Listening"); } SwarmEvent::ConnectionEstablished { peer_id, .. } => { info!("Connected to {peer_id}"); @@ -92,4 +95,4 @@ pub fn connect_to_swarm() -> Result { }); Ok(local_peer_id) -} \ No newline at end of file +} From 8d1fa6f59d14cdac94c41abbf4d1b13faf3651bb Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 7 Apr 2026 19:44:52 +0700 Subject: [PATCH 064/189] rewrite API routes to use aide --- Cargo.lock | 179 ++++++++++++++++++++++++- Cargo.toml | 14 +- odorobo-manager/Cargo.toml | 17 ++- odorobo-manager/src/api/mod.rs | 57 ++++++++ odorobo-manager/src/api/nodes.rs | 23 ++++ odorobo-manager/src/api/types.rs | 143 ++++++++++++++++++++ odorobo-manager/src/api/vms.rs | 42 ++++++ odorobo-manager/src/api/volumes.rs | 40 ++++++ odorobo-manager/src/lib.rs | 4 +- odorobo-manager/src/scheduler_actor.rs | 43 +++--- 10 files changed, 528 insertions(+), 34 deletions(-) create mode 100644 odorobo-manager/src/api/mod.rs create mode 100644 odorobo-manager/src/api/nodes.rs create mode 100644 odorobo-manager/src/api/types.rs create mode 100644 odorobo-manager/src/api/vms.rs create mode 100644 odorobo-manager/src/api/volumes.rs diff --git a/Cargo.lock b/Cargo.lock index 6f094fd..d761c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "aide" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2" +dependencies = [ + "aide-macros", + "axum", + "axum-extra 0.10.3", + "bytes", + "cfg-if", + "http 1.4.0", + "indexmap", + "schemars", + "serde", + "serde_json", + "serde_qs", + "thiserror 2.0.18", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "aide-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f2a08f14808f3c46f3e3004b727bace64af44c3c5996d0480a14d3852b1b25a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -431,6 +466,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-extra" version = "0.12.5" @@ -619,6 +676,9 @@ name = "bytesize" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +dependencies = [ + "serde_core", +] [[package]] name = "cbor4ii" @@ -968,6 +1028,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -1842,6 +1937,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -2865,7 +2966,7 @@ dependencies = [ "ahash", "async-trait", "axum", - "axum-extra", + "axum-extra 0.12.5", "axum_responses", "bytesize", "cloud-hypervisor-client", @@ -2900,7 +3001,9 @@ name = "odorobo-manager" version = "0.1.0" dependencies = [ "ahash", + "aide", "axum", + "bytesize", "env", "kameo", "libp2p", @@ -2908,6 +3011,7 @@ dependencies = [ "odorobo-shared", "optional_struct", "reqwest", + "schemars", "serde", "serde_json", "stable-eyre", @@ -2915,8 +3019,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ulid", "utoipa", - "uuid", ] [[package]] @@ -3432,6 +3536,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -3724,6 +3848,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3789,6 +3939,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_html_form" version = "0.2.8" @@ -3826,6 +3987,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_qs" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b417bedc008acbdf6d6b4bc482d29859924114bbe2650b7921fb68a261d0aa6" +dependencies = [ + "axum", + "futures", + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4465,6 +4639,7 @@ checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ "rand 0.9.2", "serde", + "uuid", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index bf2a168..18c9185 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,14 @@ axum = { version = "0.8.8", features = ["ws"] } thiserror = "2.0.18" axum-extra = "0.12" tower-http = { version = "0.6", features = ["trace"] } -tokio = { version = "1.50.0" } +tokio = { version = "1" } kameo = { version = "0.19.2", features = ["remote"] } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } ahash = { version = "0.8.12", features = ["serde"] } -ulid = { version = "1.2", features = ["serde"] } - +ulid = { version = "1.2", features = ["serde", "uuid"] } +# aide needs an older version of schemars +schemars = "0.9" +bytesize = { version = "2.3.1", features = ["serde"] } odorobo-shared = { path = "odorobo-shared" } odorobo-agent = { path = "odorobo-agent" } odoroboctl = { path = "odoroboctl" } @@ -33,7 +35,7 @@ odorobo-manager = { path = "odorobo-manager" } # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. -# you should also try to setup rustflags -C target-cpu=native, but this only works on the target machine. +# you should also try to setup rustflags -C target-cpu=native, but this only works on the target machine. # this enables microarchitecture optimizations, which is just better if we can. # You can also set the target-cpu to do cross compilation if you know the exact micro architecture of the target machine. @@ -43,10 +45,10 @@ opt-level = 3 lto = "fat" codegen-units = 1 -# we are specifically NOT using panic = "abort" because we need to use catch_unwind for kameo actors on_panic. +# we are specifically NOT using panic = "abort" because we need to use catch_unwind for kameo actors on_panic. # for any crates using kameo actors on_panic, this would literally break the binary. # For specific crates/binaries this might be something we do, but I (caleb) doubt it. strip = "symbols" # TODO: this we should talk about doing this, because it makes debugging harder, so if we have a panic in production, the backtrace will likely not be as useful. -incremental = false # this is just to get reproducible builds. incremental compiles aren't consistent and can sometimes be broken when combined with optimizations \ No newline at end of file +incremental = false # this is just to get reproducible builds. incremental compiles aren't consistent and can sometimes be broken when combined with optimizations diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index 2046ce7..00f31e5 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -6,17 +6,28 @@ edition = "2024" [dependencies] kameo = { version = "0.19.2", features = ["remote"] } axum = { version = "0.8.8", features = ["tracing", "macros"] } +aide = { version = "0.15.1", features = [ + "axum", + "axum-json", + "axum-extra", + "axum-tokio", + "axum-ws", + "swagger", + "macros", +] } +schemars.workspace = true +bytesize.workspace = true env = "1.0.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -tokio = { version = "1.50.0", features = [ +tokio = { workspace = true, features = [ "full", ] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. tower-http = { version = "0.6.8", features = ["trace"] } tracing = "0.1.44" optional_struct = "0.5.2" -tracing-subscriber = { version = "0.3.2", features = ["env-filter"] } -uuid = { version = "1.23.0", features = ["serde"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +ulid = { workspace = true } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } reqwest = { version = "0.13", features = ["json"] } ahash = { version = "0.8.12", features = ["serde"] } diff --git a/odorobo-manager/src/api/mod.rs b/odorobo-manager/src/api/mod.rs new file mode 100644 index 0000000..0206e44 --- /dev/null +++ b/odorobo-manager/src/api/mod.rs @@ -0,0 +1,57 @@ +pub mod nodes; +pub mod types; +pub mod vms; +pub mod volumes; +use aide::{ + axum::{ApiRouter, IntoApiResponse, routing::get}, + openapi::{Info, OpenApi}, + swagger::Swagger, +}; +use axum::{Extension, Json, Router}; + +/// Build the full app: finalizes the OpenAPI spec and attaches it as an extension. +pub fn build() -> Router { + aide::generate::on_error(|error| { + tracing::warn!("aide schema gen error: {error}"); + }); + + let mut openapi = OpenApi { + info: Info { + title: "odorobo-manager".into(), + version: env!("CARGO_PKG_VERSION").into(), + ..Default::default() + }, + ..Default::default() + }; + + router().finish_api(&mut openapi).layer(Extension(openapi)) +} + +// todo: error handling +// +// see odorobo-agent's old API for error handling patterns, +// use `thiserror` and `axum_responses` to create consistent error responses across the API +// - cappy + +/// Main router for the API +fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/health", get(health)) + .api_route("/swagger", Swagger::new("/openapi.json").axum_route()) + .api_route("/openapi.json", get(serve_api)) + .nest("/nodes", nodes::router()) + .nest("/vms", vms::router()) + .nest("/volumes", volumes::router()) +} + +/// Serve the OpenAPI spec as JSON +async fn serve_api(Extension(api): Extension) -> impl IntoApiResponse { + Json(api) +} + +/// Simple health check endpoint +/// +/// Returns "OK" if the server is running. +async fn health() -> &'static str { + "OK" +} diff --git a/odorobo-manager/src/api/nodes.rs b/odorobo-manager/src/api/nodes.rs new file mode 100644 index 0000000..95ce6d3 --- /dev/null +++ b/odorobo-manager/src/api/nodes.rs @@ -0,0 +1,23 @@ +//! Compute node management API handlers.//! VM management API handlers. +use crate::api::types::{Node, VmId}; +use aide::axum::{ + ApiRouter, IntoApiResponse, + routing::{get, put}, +}; +use axum::{Json, extract::Path}; +pub fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/drain", put(drain)) + .api_route("/{nodeid}", get(node_info)) +} +/// Drain a node of all VMs, migrating them away or shutting them down as needed. This is used for maintenance mode. +async fn drain() -> impl IntoApiResponse { + // stub + Json("Draining...".to_string()) +} + +/// Get detailed information about a specific node, including its current VMs and resource usage. +async fn node_info(Path(node_id): Path) -> impl IntoApiResponse { + // stub, + Json(Node::default()) +} diff --git a/odorobo-manager/src/api/types.rs b/odorobo-manager/src/api/types.rs new file mode 100644 index 0000000..670581e --- /dev/null +++ b/odorobo-manager/src/api/types.rs @@ -0,0 +1,143 @@ +use aide::OperationIo; +use bytesize::ByteSize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +// Newtype so aide can generate a path parameter schema for Ulid. +/// VM ID, in the format of ULID +#[derive(Deserialize, JsonSchema, OperationIo)] +pub struct VmId(#[schemars(with = "String")] pub Ulid); + +/// Volume ID, in the format of ULID +#[derive(Deserialize, JsonSchema)] +pub struct VolumeId(#[schemars(with = "String")] pub Ulid); + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct CreateVMRequest { + /// Data of the VM to create + pub data: VMData, + /// Whether to boot the VM immediately after creation + pub boot: bool, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct VMData { + /// VM ID. This is a ULID string. + #[schemars(with = "String")] + pub id: Ulid, + /// Name of the VM. + pub name: String, + /// Number of vCPUs allocated to the VM. + pub vcpus: u32, + /// Optional maximum number of vCPUs the VM can scale up to, if supported by the underlying hypervisor. + pub max_vcpus: Option, + /// Amount of RAM in bytes allocated to the VM. + #[schemars(with = "u64")] + pub memory: ByteSize, + /// Image used for the VM. + pub image: String, + /// List of volumes to attach to the VM. + #[serde(default)] + pub volumes: Vec, +} +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct UpdateVMRequest { + /// Updated name of the VM. + pub name: Option, + /// Updated number of vCPUs allocated to the VM. + pub vcpus: Option, + /// Updated maximum number of vCPUs the VM can scale up to, if supported by the underlying hypervisor. + pub max_vcpus: Option, + /// Updated amount of RAM in bytes allocated to the VM. + #[schemars(with = "u64")] + pub memory: Option, + /// Updated list of volumes to attach to the VM. This will replace the existing list of attached volumes. + #[serde(default)] + pub volumes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub enum VMStatus { + /// VM is currently running and operational. + Running, + /// VM is currently shut down, not running. + Stopped, + /// VM is being provisioned, being set up and started. + #[default] + Provisioning, + Error(String), // error message +} + +/// Detailed information about a running VM +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct VMInfo { + /// VM configuration + pub data: VMData, + + /// Currently scheduled node for the VM, + /// if any. + /// + /// None means the VM is not currently scheduled to any node + /// (e.g. VM is shut down, underlying volume still provisioning, compute unschedulable, etc.) + pub node: Option, + /// Current status of the VM + pub status: VMStatus, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct Volume { + /// Volume ID. This is a ULID string. + #[schemars(with = "String")] + pub id: Ulid, + /// Name of the volume. + pub name: String, + /// Size of the volume in bytes. + #[schemars(with = "u64")] + pub size: ByteSize, +} + +// for now +pub type CreateVolumeRequest = Volume; + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub enum VolumeStatus { + /// Available in the pool, not yet attached to any VM + Available, + /// Volume is currently attached to a VM, + /// This may affect scheduling by preferring a node + /// where this volume is already attached to, if possible. + Attached(String), + + /// Volume is being provisioned, being carved + /// from the pool. + #[default] + Provisioning, + Error(String), // error message +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct VolumeInfo { + pub data: Volume, + pub status: VolumeStatus, +} + +/// A compute node in the cluster. This is used for scheduling VMs to nodes. +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct Node { + /// Hostname or identifier of the node. + pub hostname: String, + /// Total number of vCPUs available on the node. + pub total_vcpus: u32, + /// Total amount of RAM in bytes available on the node. + #[schemars(with = "u64")] + pub total_memory: ByteSize, + #[serde(default)] + pub status: NodeStatus, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct NodeStatus { + /// CPU usage + pub cpu_usage: f32, +} diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs new file mode 100644 index 0000000..ea787d7 --- /dev/null +++ b/odorobo-manager/src/api/vms.rs @@ -0,0 +1,42 @@ +//! VM management API handlers. +use crate::api::types::{CreateVMRequest, UpdateVMRequest, VMInfo, VmId}; +use aide::axum::{ + ApiRouter, IntoApiResponse, + routing::{delete, get, patch, post}, +}; +use axum::{Json, extract::Path}; + +pub fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/", post(create_vm)) + .api_route("/{vmid}", get(vm_info)) + .api_route("/{vmid}", patch(update_vm)) + .api_route("/{vmid}", delete(delete_vm)) +} + +/// Get detailed information about a specific VM +async fn vm_info(Path(VmId(vmid)): Path) -> impl IntoApiResponse { + // stub, + Json(VMInfo::default()) +} + +async fn create_vm(Json(request): Json) -> impl IntoApiResponse { + // stub + Json(VMInfo::default()) +} + +async fn delete_vm(Path(VmId(vmid)): Path) -> impl IntoApiResponse { + // stub +} + +/// Update an existing VM's configuration (e.g. resize, change resources, etc.) +/// +/// todo: make new schema for update request that allows partial updates +async fn update_vm( + Path(VmId(vmid)): Path, + Json(request): Json, +) -> impl IntoApiResponse { + // stub + + Json(VMInfo::default()) +} diff --git a/odorobo-manager/src/api/volumes.rs b/odorobo-manager/src/api/volumes.rs new file mode 100644 index 0000000..121fa1e --- /dev/null +++ b/odorobo-manager/src/api/volumes.rs @@ -0,0 +1,40 @@ +//! Volume management API handlers. +use crate::api::types::{CreateVolumeRequest, Volume, VolumeId, VolumeInfo}; +use aide::axum::{ + ApiRouter, IntoApiResponse, + routing::{delete, get, patch, put}, +}; +use axum::{Json, extract::Path}; + +pub fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/", put(create_volume)) + .api_route("/{volid}", get(volume_info)) + .api_route("/{volid}", delete(delete_volume)) + .api_route("/{volid}", patch(resize_volume)) +} + +/// Get detailed information about a specific volume +async fn volume_info(Path(VolumeId(volid)): Path) -> impl IntoApiResponse { + // stub, + Json(VolumeInfo::default()) +} + +/// Create a new volume with the specified parameters +async fn create_volume(Json(request): Json) -> impl IntoApiResponse { + // stub + Json(VolumeInfo::default()) +} +/// Delete an existing volume by ID +async fn delete_volume(Path(VolumeId(volid)): Path) -> impl IntoApiResponse { + // stub +} + +/// Resize an existing volume to a new size +async fn resize_volume( + Path(VolumeId(volid)): Path, + Json(request): Json, +) -> impl IntoApiResponse { + // stub + Json(VolumeInfo::default()) +} diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs index e887c65..f252059 100644 --- a/odorobo-manager/src/lib.rs +++ b/odorobo-manager/src/lib.rs @@ -1,2 +1,2 @@ - -pub mod scheduler_actor; \ No newline at end of file +pub mod api; +pub mod scheduler_actor; diff --git a/odorobo-manager/src/scheduler_actor.rs b/odorobo-manager/src/scheduler_actor.rs index 4386ca8..3cc7ac4 100644 --- a/odorobo-manager/src/scheduler_actor.rs +++ b/odorobo-manager/src/scheduler_actor.rs @@ -1,15 +1,5 @@ -use axum::extract::State; -use axum::http::StatusCode; -use axum::routing::{get, post}; -use axum::{Json, Router}; use kameo::prelude::*; -use libp2p::PeerId; -use libp2p::futures::TryStreamExt; -use odorobo_shared::messages::server_status::GetServerStatus; -use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; -use utoipa::OpenApi; -use uuid::Uuid; //use odorobo_shared::odorobo::server_actor::ServerActor; use odorobo_agent::actor::AgentActor; use odorobo_shared::messages::create_vm::*; @@ -52,22 +42,33 @@ impl Actor for SchedulerActor { info!("Agent actor peer id: {agent_actor_peer_id}"); - let reply = agent_actor - .ask(&CreateVM { - vm_id: Default::default(), - config: Default::default(), - }) - .await?; + // run the HTTP API + tokio::spawn(async move { + tracing::info!("Starting HTTP server on {EXTERNAL_HTTP_URL}"); + let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) + .await + .unwrap(); + axum::serve(listener, crate::api::build()).await.unwrap(); + }); - info!(?reply, "Created VM Reply"); - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - warn!("Panicking Agent"); + // let reply = agent_actor + // .ask(&CreateVM { + // vm_id: Default::default(), + // config: Default::default(), + // }) + // .await?; - agent_actor.tell(&PanicAgent).send()?; + // info!(?reply, "Created VM Reply"); - error!("Agent has been panicked."); + // tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // warn!("Panicking Agent"); + + // agent_actor.tell(&PanicAgent).send()?; + + // error!("Agent has been panicked."); Ok(state) } From 7accc8b25703ef8673309819b26ccd1e43da6e01 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 7 Apr 2026 20:01:23 +0700 Subject: [PATCH 065/189] properly serialize ByteSize --- odorobo-manager/src/api/types.rs | 36 +++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/odorobo-manager/src/api/types.rs b/odorobo-manager/src/api/types.rs index 670581e..421e96c 100644 --- a/odorobo-manager/src/api/types.rs +++ b/odorobo-manager/src/api/types.rs @@ -4,6 +4,35 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ulid::Ulid; +mod bytesize_as_u64 { + use bytesize::ByteSize; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(size: &ByteSize, s: S) -> Result { + s.serialize_u64(size.as_u64()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(ByteSize(u64::deserialize(d)?)) + } +} + +mod opt_bytesize_as_u64 { + use bytesize::ByteSize; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(size: &Option, s: S) -> Result { + match size { + Some(b) => s.serialize_some(&b.as_u64()), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.map(ByteSize)) + } +} + // Newtype so aide can generate a path parameter schema for Ulid. /// VM ID, in the format of ULID #[derive(Deserialize, JsonSchema, OperationIo)] @@ -34,6 +63,7 @@ pub struct VMData { pub max_vcpus: Option, /// Amount of RAM in bytes allocated to the VM. #[schemars(with = "u64")] + #[serde(with = "bytesize_as_u64")] pub memory: ByteSize, /// Image used for the VM. pub image: String, @@ -41,6 +71,7 @@ pub struct VMData { #[serde(default)] pub volumes: Vec, } + #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] pub struct UpdateVMRequest { /// Updated name of the VM. @@ -50,7 +81,8 @@ pub struct UpdateVMRequest { /// Updated maximum number of vCPUs the VM can scale up to, if supported by the underlying hypervisor. pub max_vcpus: Option, /// Updated amount of RAM in bytes allocated to the VM. - #[schemars(with = "u64")] + #[schemars(with = "Option")] + #[serde(with = "opt_bytesize_as_u64")] pub memory: Option, /// Updated list of volumes to attach to the VM. This will replace the existing list of attached volumes. #[serde(default)] @@ -94,6 +126,7 @@ pub struct Volume { pub name: String, /// Size of the volume in bytes. #[schemars(with = "u64")] + #[serde(with = "bytesize_as_u64")] pub size: ByteSize, } @@ -131,6 +164,7 @@ pub struct Node { pub total_vcpus: u32, /// Total amount of RAM in bytes available on the node. #[schemars(with = "u64")] + #[serde(with = "bytesize_as_u64")] pub total_memory: ByteSize, #[serde(default)] pub status: NodeStatus, From 395497ed58d5bbde1a674449030a3ecbe5b37139 Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 7 Apr 2026 09:13:07 -0500 Subject: [PATCH 066/189] optimized compile --- .cargo/config.toml | 5 +++++ Cargo.toml | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..662ec19 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# we should try to use this rustflags -C target-cpu=native for microarchitecture optimizations, +# but this only works on the target machine, so we have it commented out at the moment. +# You can also set the target-cpu to do cross compilation if you know the exact micro architecture of the target machine. +[build] +#rustflags = ["-C", "target-cpu=native"] \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 18c9185..1432908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,15 +30,10 @@ odorobo-agent = { path = "odorobo-agent" } odoroboctl = { path = "odoroboctl" } odorobo-manager = { path = "odorobo-manager" } - -[profile.release-optimized] # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. - -# you should also try to setup rustflags -C target-cpu=native, but this only works on the target machine. -# this enables microarchitecture optimizations, which is just better if we can. -# You can also set the target-cpu to do cross compilation if you know the exact micro architecture of the target machine. - +# also look in .cargo/config.toml for info on microarchitecture optimizations. +[profile.release-optimized] inherits = "release" opt-level = 3 From 6e72023c12c9865f5e75165016a93565a9b4eb0d Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 7 Apr 2026 13:17:12 -0500 Subject: [PATCH 067/189] stub up IPAM and terminal actors, refactor agent architecture a bit Co-authored-by: Cappy Ishihara --- Cargo.lock | 28 +++------ Cargo.toml | 3 +- odorobo-agent/src/main.rs | 26 ++++---- odorobo-agent/src/state/mod.rs | 8 +-- .../src/state/provisioning/actor/mod.rs | 56 ++++++++++++----- odorobo-manager/Cargo.toml | 9 +-- odorobo-manager/src/actors/http_actor.rs | 30 +++++++++ .../src/actors/ip_management_actor.rs | 32 ++++++++++ odorobo-manager/src/actors/mod.rs | 4 ++ odorobo-manager/src/actors/scheduler_actor.rs | 62 +++++++++++++++++++ .../actors/serial_terminal_websocket_actor.rs | 17 +++++ odorobo-manager/src/actors/storage_actor.rs | 17 +++++ odorobo-manager/src/lib.rs | 1 + odorobo-manager/src/main.rs | 25 ++++++++ odorobo-shared/src/messages/ip_management.rs | 0 15 files changed, 262 insertions(+), 56 deletions(-) create mode 100644 odorobo-manager/src/actors/http_actor.rs create mode 100644 odorobo-manager/src/actors/ip_management_actor.rs create mode 100644 odorobo-manager/src/actors/mod.rs create mode 100644 odorobo-manager/src/actors/scheduler_actor.rs create mode 100644 odorobo-manager/src/actors/serial_terminal_websocket_actor.rs create mode 100644 odorobo-manager/src/actors/storage_actor.rs create mode 100644 odorobo-shared/src/messages/ip_management.rs diff --git a/Cargo.lock b/Cargo.lock index d761c97..f3fd4f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,6 +1144,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "2.0.2" @@ -1246,15 +1252,6 @@ dependencies = [ "syn", ] -[[package]] -name = "env" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc95de49ad098572c02d3fbf368c9a020bfff5ae78483685b77f51d8a7e9486d" -dependencies = [ - "num_threads", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -2922,15 +2919,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3004,7 +2992,8 @@ dependencies = [ "aide", "axum", "bytesize", - "env", + "clap", + "dotenvy", "kameo", "libp2p", "odorobo-agent", @@ -3020,7 +3009,6 @@ dependencies = [ "tracing", "tracing-subscriber", "ulid", - "utoipa", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1432908..d45be1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,8 @@ odorobo-shared = { path = "odorobo-shared" } odorobo-agent = { path = "odorobo-agent" } odoroboctl = { path = "odoroboctl" } odorobo-manager = { path = "odorobo-manager" } - +dotenvy = "0.15" +clap = { version = "4", features = ["derive", "env"] } # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. # also look in .cargo/config.toml for info on microarchitecture optimizations. diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 0130485..1546da2 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -16,7 +16,21 @@ async fn main() -> Result<()> { tracing::info!("Starting odorobo-agent..."); - // minimal axum server + // minimal axum server, debug socket + // + // todo: remove this, here to stub out dead code + tokio::task::spawn(async { + let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; + let port = listener.local_addr()?.port(); + let addrs: Vec = if_addrs::get_if_addrs()? + .into_iter() + .filter(|i| !i.is_loopback()) + .map(|i| format!("http://{}:{}", i.ip(), port)) + .collect(); + tracing::info!(port, ?addrs, "Listening"); + axum::serve(listener, api::router(port)).await?; + Ok::<(), stable_eyre::Report>(()) + }); let local_peer_id = connect_to_swarm().unwrap(); tracing::info!(?local_peer_id, "Peer ID"); @@ -26,15 +40,5 @@ async fn main() -> Result<()> { actor_ref.wait_for_shutdown().await; - // let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; - // let port = listener.local_addr()?.port(); - // let addrs: Vec = if_addrs::get_if_addrs()? - // .into_iter() - // .filter(|i| !i.is_loopback()) - // .map(|i| format!("http://{}:{}", i.ip(), port)) - // .collect(); - // tracing::info!(port, ?addrs, "Listening"); - // axum::serve(listener, api::router(port)).await?; - Ok(()) } diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 0cb95d9..8bea936 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -3,11 +3,11 @@ //! Runtime state (in /run) is not persisted across reboots, so we use it for //! ephemeral VM state like running instances. Persistent state goes in the database. -mod api; -mod instance; -mod devices; +pub mod api; +pub mod instance; +pub mod devices; pub mod provisioning; -mod transform; +pub mod transform; pub use api::{call, call_request}; pub use instance::{CONFIG_FILE_NAME, ChApiError, ConsoleStream, VMInstance, VMS_DIR_NAME}; diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index b8870ea..5a14b46 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -3,8 +3,10 @@ use crate::state::VMInstance; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; use stable_eyre::Report; -use stable_eyre::{Result}; -use std::path::PathBuf; /* +use stable_eyre::Result; +use std::path::PathBuf; +use crate::state::provisioning::default_provisioner; +/* use std::process::Command; let output = Command::new("echo") @@ -20,17 +22,39 @@ pub struct VMActor { pub vm_config: VmConfig, /// path to the Cloud Hypervisor socket, in /run/odorobo/vms//ch.sock pub ch_socket_path: PathBuf, + // handle to the Cloud Hypervisor process + // process_handle: tokio::process::Child, } impl Actor for VMActor { - type Args = Self; + // tuple of VM ID and config + type Args = (ulid::Ulid, VmConfig); type Error = Report; #[tracing::instrument(skip_all)] - async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { - + async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { + let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); + tracing::warn!("no-op"); - Ok(state) + // spawn CH instance + // this probably is not an ideal way to do this, but we want a minimal thing + // so let's spawn CH as a child + // + // ...or we go back to that systemd way + // let ch_process = tokio::process::Command::new("cloud-hypervisor") + // .arg("--api-socket") + // .arg(&ch_sock_path); + + // use the provisioner to spawn the VM instance + // consider spawning transient services instead for easier code deployment + default_provisioner().start_instance(&vmid.to_string(), &vm_config).await?; + + Ok(Self { + vmid, + vm_config, + ch_socket_path: ch_sock_path, + // process_handle: ch_process.spawn()?, + }) } } @@ -44,15 +68,15 @@ impl From for VMInstance { } } -/// Provisioner backend for VM instances using an actor-based model -pub struct ActorProvisioner; +// /// Provisioner backend for VM instances using an actor-based model +// pub struct ActorProvisioner; -impl VMProvisionerBackend for ActorProvisioner { - async fn start_instance(&self, vmid: &str) -> Result { - todo!() - } +// impl VMProvisionerBackend for ActorProvisioner { +// async fn start_instance(&self, vmid: &str) -> Result { +// todo!() +// } - async fn stop_instance(&self, vmid: &str) -> Result<()> { - todo!() - } -} +// async fn stop_instance(&self, vmid: &str) -> Result<()> { +// todo!() +// } +// } diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index 00f31e5..9bcaad8 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -16,22 +16,23 @@ aide = { version = "0.15.1", features = [ "macros", ] } schemars.workspace = true +clap.workspace = true bytesize.workspace = true -env = "1.0.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { workspace = true, features = [ "full", ] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. tower-http = { version = "0.6.8", features = ["trace"] } -tracing = "0.1.44" +dotenvy = { workspace = true } +tracing = { workspace = true } optional_struct = "0.5.2" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { workspace = true } ulid = { workspace = true } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } reqwest = { version = "0.13", features = ["json"] } ahash = { version = "0.8.12", features = ["serde"] } -utoipa = { version = "5.4.0", features = ["uuid"] } +# utoipa = { version = "5.4.0", features = ["uuid"] } odorobo-shared = { workspace = true } stable-eyre = { workspace = true } odorobo-agent = { workspace = true } diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs new file mode 100644 index 0000000..ae8be79 --- /dev/null +++ b/odorobo-manager/src/actors/http_actor.rs @@ -0,0 +1,30 @@ +use kameo::prelude::*; +use tracing::{error, info, warn}; +//use odorobo_shared::odorobo::server_actor::ServerActor; +use odorobo_agent::actor::AgentActor; +use odorobo_shared::messages::create_vm::*; +use odorobo_shared::messages::debug::PanicAgent; +use stable_eyre::{Report, Result}; +const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; +const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: mak +/// HTTP REST API service +#[derive(RemoteActor)] +pub struct HTTPActor; + +impl Actor for HTTPActor { + type Args = (); + type Error = Report; + + async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + // run the HTTP API + tokio::spawn(async move { + tracing::info!("Starting HTTP server on {EXTERNAL_HTTP_URL}"); + let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) + .await + .unwrap(); + axum::serve(listener, crate::api::build()).await.unwrap(); + }); + + Ok(Self) + } +} diff --git a/odorobo-manager/src/actors/ip_management_actor.rs b/odorobo-manager/src/actors/ip_management_actor.rs new file mode 100644 index 0000000..5c590dc --- /dev/null +++ b/odorobo-manager/src/actors/ip_management_actor.rs @@ -0,0 +1,32 @@ +use kameo::prelude::*; +use stable_eyre::{Report, Result}; +use tracing::{error, info, warn}; + +// idk if we ever agreed upon an OUI for fyra, but im reserving `FYR` for this +// -cappy +pub const FYRA_OUI: [u8; 3] = [0x46, 0x59, 0x52]; + +/// Calculates a MAC address for a given IP address using the FYRA OUI prefix. +/// +/// Takes the last 3 bytes of the IP address and combines them with the FYRA OUI prefix. +pub fn calculate_mac_address(ip: [u8; 4]) -> [u8; 6] { + let mut mac = [0u8; 6]; + mac[0..2].copy_from_slice(&FYRA_OUI); + mac[2..].copy_from_slice(&ip[2..]); + mac +} + +/// HTTP REST API service +#[derive(RemoteActor)] +pub struct IPManagementActor; + +impl Actor for IPManagementActor { + type Args = (); + type Error = Report; + + async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + // if we need to like prep the router stuff + + Ok(Self) + } +} diff --git a/odorobo-manager/src/actors/mod.rs b/odorobo-manager/src/actors/mod.rs new file mode 100644 index 0000000..1d943e9 --- /dev/null +++ b/odorobo-manager/src/actors/mod.rs @@ -0,0 +1,4 @@ +mod http_actor; +mod scheduler_actor; +mod serial_terminal_websocket_actor; +mod ip_management_actor; \ No newline at end of file diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs new file mode 100644 index 0000000..a0ef53a --- /dev/null +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -0,0 +1,62 @@ +use kameo::prelude::*; +use tower_http::classify::SharedClassifier; +use tracing::{error, info, warn}; +//use odorobo_shared::odorobo::server_actor::ServerActor; +use odorobo_agent::actor::AgentActor; +use odorobo_shared::messages::create_vm::*; +use odorobo_shared::messages::debug::PanicAgent; +use stable_eyre::{Report, Result}; + +#[derive(RemoteActor)] +pub struct SchedulerActor; + +impl Actor for SchedulerActor { + type Args = (); + type Error = Report; + + async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + let peer_id = actor_ref.id().peer_id().unwrap().clone(); + + info!("Actor started! Scheduler peer id: {peer_id}"); + + let mut agent_actor: Option> = None; + + loop { + let agent_actor_option = RemoteActorRef::::lookup("agent").await?; + + let Some(agent_actor_in_loop) = agent_actor_option else { + continue; + }; + + agent_actor = Some(agent_actor_in_loop); + break; + } + + let agent_actor = agent_actor.unwrap(); + + let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); + + info!("Agent actor peer id: {agent_actor_peer_id}"); + + + + // let reply = agent_actor + // .ask(&CreateVM { + // vm_id: Default::default(), + // config: Default::default(), + // }) + // .await?; + + // info!(?reply, "Created VM Reply"); + + // tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // warn!("Panicking Agent"); + + // agent_actor.tell(&PanicAgent).send()?; + + // error!("Agent has been panicked."); + + Ok(Self) + } +} diff --git a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs new file mode 100644 index 0000000..d373b5c --- /dev/null +++ b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs @@ -0,0 +1,17 @@ +use kameo::prelude::*; +use tracing::{error, info, warn}; +use stable_eyre::{Report, Result}; + + +/// HTTP REST API service +#[derive(RemoteActor)] +pub struct SerialTerminalWebsocketActor; + +impl Actor for SerialTerminalWebsocketActor { + type Args = (); + type Error = Report; + + async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + todo!() + } +} diff --git a/odorobo-manager/src/actors/storage_actor.rs b/odorobo-manager/src/actors/storage_actor.rs new file mode 100644 index 0000000..d373b5c --- /dev/null +++ b/odorobo-manager/src/actors/storage_actor.rs @@ -0,0 +1,17 @@ +use kameo::prelude::*; +use tracing::{error, info, warn}; +use stable_eyre::{Report, Result}; + + +/// HTTP REST API service +#[derive(RemoteActor)] +pub struct SerialTerminalWebsocketActor; + +impl Actor for SerialTerminalWebsocketActor { + type Args = (); + type Error = Report; + + async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + todo!() + } +} diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs index f252059..9353e6b 100644 --- a/odorobo-manager/src/lib.rs +++ b/odorobo-manager/src/lib.rs @@ -1,2 +1,3 @@ pub mod api; pub mod scheduler_actor; +mod actors; \ No newline at end of file diff --git a/odorobo-manager/src/main.rs b/odorobo-manager/src/main.rs index 7ac2a47..8b504c3 100644 --- a/odorobo-manager/src/main.rs +++ b/odorobo-manager/src/main.rs @@ -1,14 +1,39 @@ +use clap::Parser; use kameo::prelude::*; use odorobo_manager::scheduler_actor::SchedulerActor; use odorobo_shared::connect_to_swarm; +use serde::{Deserialize, Serialize}; use stable_eyre::Result; use tracing::info; + +// A config we definitely need: what is the router ip. +// TODO: talk to katherine about exactly how they want this config, since they may or may not be doing some of this, and we dont know if they want a .json for this or .env or something else. +#[derive(Serialize, Deserialize, Debug, Parser)] +struct Config { + /// Comma-separated list of actors to enable. + #[clap( + env = "ODOROBO_ACTORS", + default_value = "api,scheduler", + value_delimiter = ',' + )] + enabled_actors: Vec, +} + +// #[derive(Serialize, Deserialize, Debug)] +// struct EnabledActorsConfig { +// /// http should probably almost always be true, but its a config just in case. +// http: bool, +// /// scheduler +// scheduler: bool, +// } + #[tokio::main] async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; odorobo_shared::utils::init()?; + dotenvy::dotenv()?; info!("Starting odorobo-manager"); let actor_ref = SchedulerActor::spawn(SchedulerActor {}); diff --git a/odorobo-shared/src/messages/ip_management.rs b/odorobo-shared/src/messages/ip_management.rs new file mode 100644 index 0000000..e69de29 From fa39e591a50d495aa08a7fc9f822473f8b3fdd8a Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 9 Apr 2026 00:28:12 +0700 Subject: [PATCH 068/189] stub child spawner, refactor manager --- odorobo-agent/src/actor/mod.rs | 10 +-- .../src/state/provisioning/actor/mod.rs | 64 ++++++++++++---- odorobo-manager/src/actors/mod.rs | 8 +- odorobo-manager/src/lib.rs | 3 +- odorobo-manager/src/main.rs | 16 ++-- odorobo-manager/src/scheduler_actor.rs | 75 ------------------- 6 files changed, 68 insertions(+), 108 deletions(-) delete mode 100644 odorobo-manager/src/scheduler_actor.rs diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 8cdb699..5e72963 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -7,10 +7,10 @@ use odorobo_shared::messages::create_vm::*; use odorobo_shared::messages::debug::PanicAgent; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; -use tracing::error; use std::ops::ControlFlow; use std::{fs, path::PathBuf}; use sysinfo::System; +use tracing::error; use kameo::error::PanicError; @@ -101,13 +101,9 @@ impl Message for AgentActor { ) -> Self::Reply { // TODO: this is unfinished. we intend on using the state::provisioning::actor stuff for this I think. let vmid = ulid::Ulid::new(); - let actor_ref = VMActor::spawn(VMActor { - ch_socket_path: PathBuf::from(format!("/run/odorobo/vms/{}/ch.sock", vmid)), - vmid, - vm_config: Default::default(), - }); + let actor_ref = VMActor::spawn((vmid, msg.config)); - let actor_registration_result = actor_ref.register("vm").await; + let _ = actor_ref.register("vm").await; tracing::info!("someone asked us for available capacity"); CreateVMReply { diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 5a14b46..e2f770c 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -1,11 +1,14 @@ use super::VMProvisionerBackend; use crate::state::VMInstance; +use crate::state::provisioning::default_provisioner; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; use stable_eyre::Report; use stable_eyre::Result; use std::path::PathBuf; -use crate::state::provisioning::default_provisioner; +use tracing::error; +use tracing::info; +use tracing::warn; /* use std::process::Command; @@ -21,9 +24,9 @@ pub struct VMActor { /// Pre-transform config, transformed config goes into the CH instance itself pub vm_config: VmConfig, /// path to the Cloud Hypervisor socket, in /run/odorobo/vms//ch.sock - pub ch_socket_path: PathBuf, + pub vm_instance: VMInstance, // handle to the Cloud Hypervisor process - // process_handle: tokio::process::Child, + process_handle: tokio::process::Child, } impl Actor for VMActor { @@ -35,36 +38,67 @@ impl Actor for VMActor { async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); + let vminstance = VMInstance::new(&vmid.to_string(), ch_sock_path.clone()); + tracing::warn!("no-op"); // spawn CH instance // this probably is not an ideal way to do this, but we want a minimal thing // so let's spawn CH as a child // // ...or we go back to that systemd way - // let ch_process = tokio::process::Command::new("cloud-hypervisor") - // .arg("--api-socket") - // .arg(&ch_sock_path); - // use the provisioner to spawn the VM instance - // consider spawning transient services instead for easier code deployment - default_provisioner().start_instance(&vmid.to_string(), &vm_config).await?; + // ownership quirk + // let value = ch_sock_path.clone(); + let ch_process = tokio::process::Command::new("cloud-hypervisor") + .arg("--api-socket") + .arg(&ch_sock_path) + .spawn()?; + // tokio::spawn(async move { + + // Ok::<_, Report>(ch_process) + // }); Ok(Self { vmid, vm_config, - ch_socket_path: ch_sock_path, - // process_handle: ch_process.spawn()?, + vm_instance: vminstance, + process_handle: ch_process, }) } + + async fn on_stop( + &mut self, + actor_ref: WeakActorRef, + reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + match reason { + ActorStopReason::Normal => { + info!(vmid = %self.vmid, "stopping VM instance"); + } + ActorStopReason::Killed => { + error!(vmid = %self.vmid, "VM killed"); + } + ActorStopReason::Panicked(err) => { + error!(vmid = %self.vmid, ?err, "VM panicked"); + } + _ => { + warn!(vmid = %self.vmid, "unknown stop reason"); + } + } + + self.vm_instance.destroy().await?; + + let res = self.process_handle.wait().await; + info!(vmid = %self.vmid, ?res, "VM process exited"); + + Ok(()) + } } // allow conversion from VMActor to VMInstance to call API impl From for VMInstance { fn from(actor: VMActor) -> Self { - Self { - id: actor.vmid.into(), - ch_socket_path: actor.ch_socket_path, - } + actor.vm_instance } } diff --git a/odorobo-manager/src/actors/mod.rs b/odorobo-manager/src/actors/mod.rs index 1d943e9..89ee40c 100644 --- a/odorobo-manager/src/actors/mod.rs +++ b/odorobo-manager/src/actors/mod.rs @@ -1,4 +1,4 @@ -mod http_actor; -mod scheduler_actor; -mod serial_terminal_websocket_actor; -mod ip_management_actor; \ No newline at end of file +pub mod http_actor; +pub mod scheduler_actor; +pub mod serial_terminal_websocket_actor; +pub mod ip_management_actor; \ No newline at end of file diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs index 9353e6b..dfc5c83 100644 --- a/odorobo-manager/src/lib.rs +++ b/odorobo-manager/src/lib.rs @@ -1,3 +1,2 @@ pub mod api; -pub mod scheduler_actor; -mod actors; \ No newline at end of file +pub mod actors; \ No newline at end of file diff --git a/odorobo-manager/src/main.rs b/odorobo-manager/src/main.rs index 8b504c3..b6f3437 100644 --- a/odorobo-manager/src/main.rs +++ b/odorobo-manager/src/main.rs @@ -1,12 +1,11 @@ use clap::Parser; use kameo::prelude::*; -use odorobo_manager::scheduler_actor::SchedulerActor; +use odorobo_manager::actors::{http_actor::HTTPActor, scheduler_actor::SchedulerActor}; use odorobo_shared::connect_to_swarm; use serde::{Deserialize, Serialize}; use stable_eyre::Result; use tracing::info; - // A config we definitely need: what is the router ip. // TODO: talk to katherine about exactly how they want this config, since they may or may not be doing some of this, and we dont know if they want a .json for this or .env or something else. #[derive(Serialize, Deserialize, Debug, Parser)] @@ -33,13 +32,20 @@ async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; odorobo_shared::utils::init()?; - dotenvy::dotenv()?; + dotenvy::dotenv().ok(); info!("Starting odorobo-manager"); - let actor_ref = SchedulerActor::spawn(SchedulerActor {}); + let actor_ref = SchedulerActor::spawn(()); + let http_actor = HTTPActor::spawn(()); + actor_ref.register("scheduler").await?; + http_actor.register("apiserver").await?; + - actor_ref.wait_for_shutdown().await; + tokio::join!( + actor_ref.wait_for_shutdown(), + http_actor.wait_for_shutdown(), + ); Ok(()) } diff --git a/odorobo-manager/src/scheduler_actor.rs b/odorobo-manager/src/scheduler_actor.rs deleted file mode 100644 index 3cc7ac4..0000000 --- a/odorobo-manager/src/scheduler_actor.rs +++ /dev/null @@ -1,75 +0,0 @@ -use kameo::prelude::*; -use tracing::{error, info, warn}; -//use odorobo_shared::odorobo::server_actor::ServerActor; -use odorobo_agent::actor::AgentActor; -use odorobo_shared::messages::create_vm::*; -use odorobo_shared::messages::debug::PanicAgent; -use stable_eyre::{Report, Result}; - -#[derive(RemoteActor)] -pub struct SchedulerActor {} - -const PING_RETURN_VALUE: &str = "pong"; -const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; - -const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: make this based on EXTERNAL_HTTP_ADDRESS. const compile time stuff is a pain. - -impl Actor for SchedulerActor { - type Args = Self; - type Error = Report; - - async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { - let peer_id = actor_ref.id().peer_id().unwrap().clone(); - - info!("Actor started! Scheduler peer id: {peer_id}"); - - let mut agent_actor: Option> = None; - - loop { - let agent_actor_option = RemoteActorRef::::lookup("agent").await?; - - let Some(agent_actor_in_loop) = agent_actor_option else { - continue; - }; - - agent_actor = Some(agent_actor_in_loop); - break; - } - - let agent_actor = agent_actor.unwrap(); - - let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); - - info!("Agent actor peer id: {agent_actor_peer_id}"); - - // run the HTTP API - tokio::spawn(async move { - tracing::info!("Starting HTTP server on {EXTERNAL_HTTP_URL}"); - let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) - .await - .unwrap(); - axum::serve(listener, crate::api::build()).await.unwrap(); - }); - - - - // let reply = agent_actor - // .ask(&CreateVM { - // vm_id: Default::default(), - // config: Default::default(), - // }) - // .await?; - - // info!(?reply, "Created VM Reply"); - - // tokio::time::sleep(std::time::Duration::from_secs(10)).await; - - // warn!("Panicking Agent"); - - // agent_actor.tell(&PanicAgent).send()?; - - // error!("Agent has been panicked."); - - Ok(state) - } -} From 56428e7cd8c5f94e7c0f76df660ef751d2cc57b7 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 9 Apr 2026 23:34:27 +0700 Subject: [PATCH 069/189] Update create_vm.rs --- odorobo-shared/src/messages/create_vm.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/odorobo-shared/src/messages/create_vm.rs b/odorobo-shared/src/messages/create_vm.rs index adab0ed..049c0f4 100644 --- a/odorobo-shared/src/messages/create_vm.rs +++ b/odorobo-shared/src/messages/create_vm.rs @@ -1,5 +1,4 @@ use cloud_hypervisor_client::models::VmConfig; -use kameo::Reply; use kameo::prelude::*; use serde::{Deserialize, Serialize}; use ulid::Ulid; From 909f0fedd4fa0e8021cbfa928c0c825e21b267dc Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 02:28:42 +0700 Subject: [PATCH 070/189] implement some HTTP actor routes, VM agent cache --- Cargo.lock | 1 + odorobo-agent/src/actor/mod.rs | 114 +++++- odorobo-agent/src/api/error.rs | 2 +- odorobo-agent/src/api/vm.rs | 324 +++++++++--------- odorobo-agent/src/lib.rs | 2 +- odorobo-agent/src/main.rs | 4 +- odorobo-agent/src/state/devices.rs | 3 +- odorobo-agent/src/state/instance.rs | 159 ++++++--- odorobo-agent/src/state/mod.rs | 2 +- .../src/state/provisioning/actor/mod.rs | 46 +-- odorobo-agent/src/state/provisioning/mod.rs | 4 +- odorobo-agent/src/state/transform/mod.rs | 8 +- odorobo-agent/src/util.rs | 2 +- odorobo-manager/Cargo.toml | 1 + odorobo-manager/src/actors/http_actor.rs | 69 +++- odorobo-manager/src/actors/mod.rs | 2 +- odorobo-manager/src/actors/scheduler_actor.rs | 129 +++++-- .../actors/serial_terminal_websocket_actor.rs | 3 +- odorobo-manager/src/api/mod.rs | 23 +- odorobo-manager/src/api/nodes.rs | 20 +- odorobo-manager/src/api/types.rs | 20 +- odorobo-manager/src/api/vms.rs | 57 ++- odorobo-manager/src/api/volumes.rs | 24 +- odorobo-manager/src/lib.rs | 2 +- odorobo-manager/src/main.rs | 11 +- odorobo-shared/src/error.rs | 3 +- odorobo-shared/src/messages/create_vm.rs | 26 ++ odorobo-shared/src/messages/mod.rs | 11 + odorobo-shared/src/messages/server_status.rs | 6 +- odorobo-shared/src/utils.rs | 24 +- odoroboctl/src/cli.rs | 235 +------------ 31 files changed, 757 insertions(+), 580 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3fd4f5..b5eaf34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2993,6 +2993,7 @@ dependencies = [ "axum", "bytesize", "clap", + "cloud-hypervisor-client", "dotenvy", "kameo", "libp2p", diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 5e72963..d1b917c 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -5,12 +5,14 @@ use kameo::error::ActorStopReason; use kameo::prelude::*; use odorobo_shared::messages::create_vm::*; use odorobo_shared::messages::debug::PanicAgent; +use odorobo_shared::messages::{Ping, Pong}; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; +use std::fs; use std::ops::ControlFlow; -use std::{fs, path::PathBuf}; use sysinfo::System; -use tracing::error; +use tracing::{error, info, trace, warn}; +use ulid::Ulid; use kameo::error::PanicError; @@ -19,6 +21,7 @@ pub struct AgentActor { pub vcpus: u32, pub memory: ByteSize, pub config: Config, + pub vms: AHashMap>, } /// Gets the system hostname @@ -31,6 +34,17 @@ pub fn default_reserved_vcpus() -> u32 { 2 } +fn default_datacenter() -> String { + warn!("No datacenter specified, defaulting to Dev"); + + "Dev".into() +} + +fn default_region() -> String { + warn!("No region specified, defaulting to Local"); + "Local".into() +} + // The infra team wants a config file on the box where they can set info specific for the box its on. // TODO: Double check with infra team (katherine) if they want any other config on the box. #[derive(Serialize, Deserialize)] @@ -40,8 +54,10 @@ pub struct Config { #[serde(default = "hostname")] pub hostname: String, /// The datacenter the agent is running in. + #[serde(default = "default_datacenter")] pub datacenter: String, /// The region the agent is running in. + #[serde(default = "default_region")] pub region: String, /// The number of VCPUs reserved for the agent. Defaults to 2. #[serde(default = "default_reserved_vcpus")] @@ -70,6 +86,7 @@ impl Actor for AgentActor { vcpus: sys.cpus().len() as u32, memory: ByteSize::b(sys.total_memory()), config, + vms: AHashMap::new(), }) } @@ -94,21 +111,98 @@ impl Actor for AgentActor { impl Message for AgentActor { type Reply = CreateVMReply; + async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { + let vmid = msg.vm_id; + let actor_ref = VMActor::spawn((vmid, msg.config.clone())); + + let _ = actor_ref.register(vmid.to_string()).await; + self.vms.insert(vmid, actor_ref.clone()); + + trace!(?vmid, "spawned VM actor, linking to context"); + ctx.actor_ref().link(&actor_ref).await; + + info!(?vmid, "VM Spawned successfully"); + CreateVMReply { + config: Some(msg.config), + } + } +} + +#[remote_message] +impl Message for AgentActor { + type Reply = DeleteVMReply; + async fn handle( &mut self, - msg: CreateVM, + msg: DeleteVM, _ctx: &mut Context, ) -> Self::Reply { - // TODO: this is unfinished. we intend on using the state::provisioning::actor stuff for this I think. - let vmid = ulid::Ulid::new(); - let actor_ref = VMActor::spawn((vmid, msg.config)); + match self.vms.remove(&msg.vm_id) { + Some(actor_ref) => { + let res = actor_ref.stop_gracefully().await; + if let Err(err) = res { + // probably a bad way to do this + warn!(vm_id = %msg.vm_id, ?err, "failed to stop VM actor gracefully, killing"); + actor_ref.kill(); + } + } + None => { + warn!(vm_id = %msg.vm_id, "VM actor not found for delete"); + } + } - let _ = actor_ref.register("vm").await; + DeleteVMReply + } +} - tracing::info!("someone asked us for available capacity"); - CreateVMReply { - config: Default::default(), +#[remote_message] +impl Message for AgentActor { + type Reply = ShutdownVMReply; + + async fn handle( + &mut self, + msg: ShutdownVM, + _ctx: &mut Context, + ) -> Self::Reply { + match self.vms.get(&msg.vm_id) { + Some(actor_ref) => { + let res = actor_ref.stop_gracefully().await; + if let Err(err) = res { + // probably a bad way to do this + warn!(vm_id = %msg.vm_id, ?err, "failed to stop VM actor gracefully, killing"); + actor_ref.kill(); + } + } + None => { + warn!(vm_id = %msg.vm_id, "VM actor not found for shutdown"); + } } + + ShutdownVMReply + } +} + +#[remote_message] +impl Message for AgentActor { + type Reply = AgentListVMsReply; + + async fn handle( + &mut self, + _msg: AgentListVMs, + _ctx: &mut Context, + ) -> Self::Reply { + let vms = self.vms.keys().copied().collect(); + + AgentListVMsReply { vms } + } +} + +#[remote_message] +impl Message for AgentActor { + type Reply = Pong; + + async fn handle(&mut self, _msg: Ping, _ctx: &mut Context) -> Self::Reply { + Pong } } diff --git a/odorobo-agent/src/api/error.rs b/odorobo-agent/src/api/error.rs index 4c85abb..ab53845 100644 --- a/odorobo-agent/src/api/error.rs +++ b/odorobo-agent/src/api/error.rs @@ -1,4 +1,4 @@ -use axum_responses::{thiserror::Error, HttpError}; +use axum_responses::{HttpError, thiserror::Error}; use stable_eyre::Report; use crate::state::ChApiError; diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index a62413c..24b876d 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -13,30 +13,30 @@ use crate::state::VMInstance; pub fn router() -> axum::Router<()> { axum::Router::new() .route("/", axum::routing::get(list_vms)) - .route("/{vmid}", axum::routing::put(spawn_vm)) - .route("/{vmid}/config", axum::routing::put(create_vm_config)) - .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) - .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) - .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) - .route("/{vmid}/boot", axum::routing::put(boot_vm)) - .route("/{vmid}/pause", axum::routing::put(pause_vm)) - .route("/{vmid}/resume", axum::routing::put(resume_vm)) - .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) - .route( - "/{vmid}/migrate/receive", - axum::routing::put(migrate_receive_vm), - ) - .route("/{vmid}", axum::routing::get(vm_info)) - .route("/{vmid}/ping", axum::routing::get(ping_vm)) - .route("/{vmid}", axum::routing::delete(destroy_vm)) - .route( - "/{vmid}/console", - axum::routing::get(super::console::console_stream), - ) - .route( - "/{vmid}/ch/{*path}", - axum::routing::any(super::ch::passthrough), - ) + // .route("/{vmid}", axum::routing::put(spawn_vm)) + // .route("/{vmid}/config", axum::routing::put(create_vm_config)) + // .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) + // .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) + // .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) + // .route("/{vmid}/boot", axum::routing::put(boot_vm)) + // .route("/{vmid}/pause", axum::routing::put(pause_vm)) + // .route("/{vmid}/resume", axum::routing::put(resume_vm)) + // .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) + // .route( + // "/{vmid}/migrate/receive", + // axum::routing::put(migrate_receive_vm), + // ) + // .route("/{vmid}", axum::routing::get(vm_info)) + // .route("/{vmid}/ping", axum::routing::get(ping_vm)) + // .route("/{vmid}", axum::routing::delete(destroy_vm)) + // .route( + // "/{vmid}/console", + // axum::routing::get(super::console::console_stream), + // ) + // .route( + // "/{vmid}/ch/{*path}", + // axum::routing::any(super::ch::passthrough), + // ) } /// Lists all VMs by their IDs @@ -103,158 +103,158 @@ pub struct VmMigrateSendRequest { pub local: bool, } -/// Sends a live migration to the given destination URL. -/// Note: the source VMM exits after migration completes, so no VM info is returned. -async fn migrate_send_vm( - vmid: Path, - Json(body): Json, -) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.send_migration(&body.destination, body.local) - .await - .map_err(ApiError::migration)?; - Ok(Json(VmMigrateSendResponse { info: None })) -} +// /// Sends a live migration to the given destination URL. +// /// Note: the source VMM exits after migration completes, so no VM info is returned. +// async fn migrate_send_vm( +// vmid: Path, +// Json(body): Json, +// ) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.send_migration(&body.destination, body.local) +// .await +// .map_err(ApiError::migration)?; +// Ok(Json(VmMigrateSendResponse { info: None })) +// } -/// Prepares a VM to receive a live migration, returning the address the sender should connect to -async fn migrate_receive_vm( - vmid: Path, -) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - let listening_address = vm.receive_migration().await.map_err(ApiError::migration)?; - Ok(Json(VmMigrateReceiveResponse { listening_address })) -} +// /// Prepares a VM to receive a live migration, returning the address the sender should connect to +// async fn migrate_receive_vm( +// vmid: Path, +// ) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// let listening_address = vm.receive_migration().await.map_err(ApiError::migration)?; +// Ok(Json(VmMigrateReceiveResponse { listening_address })) +// } -/// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately -async fn spawn_vm( - vmid: Path, - Query(query): Query, - vm_config: Option>, -) -> Result, ApiError> { - VMInstance::validate_vmid(&vmid.0).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; - let vm_config = vm_config.map(|Json(vm_config)| vm_config); +// /// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately +// async fn spawn_vm( +// vmid: Path, +// Query(query): Query, +// vm_config: Option>, +// ) -> Result, ApiError> { +// VMInstance::validate_vmid(&vmid.0).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; +// let vm_config = vm_config.map(|Json(vm_config)| vm_config); - // check if VM already exists, if so error out for already existing instance - if VMInstance::get(&vmid.0).is_some() { - error!(vmid = ?vmid, "VM with this ID already exists"); - return Err(ApiError::CreateFailed { - msg: "VM with this ID already exists".to_string(), - errors: vec![], - }); - } +// // check if VM already exists, if so error out for already existing instance +// if VMInstance::get(&vmid.0).is_some() { +// error!(vmid = ?vmid, "VM with this ID already exists"); +// return Err(ApiError::CreateFailed { +// msg: "VM with this ID already exists".to_string(), +// errors: vec![], +// }); +// } - trace!(?vmid, ?query, "Creating VM with config"); - // trace!(?vm_config, "VM config details"); - let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); - std::fs::create_dir_all(&runtime_dir).map_err(|e| { - error!(error = %e, "Failed to create runtime dir"); - ApiError::CreateFailed { - msg: e.to_string(), - errors: vec![], - } - })?; - // trace!(?) +// trace!(?vmid, ?query, "Creating VM with config"); +// // trace!(?vm_config, "VM config details"); +// let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); +// std::fs::create_dir_all(&runtime_dir).map_err(|e| { +// error!(error = %e, "Failed to create runtime dir"); +// ApiError::CreateFailed { +// msg: e.to_string(), +// errors: vec![], +// } +// })?; +// // trace!(?) - let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { - error!(error = ?e, "Failed to spawn VM process"); - ApiError::create(e) - })?; +// let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { +// error!(error = ?e, "Failed to spawn VM process"); +// ApiError::create(e) +// })?; - let mut created = false; - if vm_config.is_some() { - trace!(?vmid, "Creating VM with provided config"); - vm.create(vm_config.clone().unwrap(), query.boot) - .await - .map_err(|e| { - error!(error = ?e, "Failed to create VM"); - ApiError::create_config(e) - })?; +// let mut created = false; +// if vm_config.is_some() { +// trace!(?vmid, "Creating VM with provided config"); +// vm.create(vm_config.clone().unwrap(), query.boot) +// .await +// .map_err(|e| { +// error!(error = ?e, "Failed to create VM"); +// ApiError::create_config(e) +// })?; - created = true; - } else { - trace!(?vmid, "No VM config provided, skipping creation step"); - } +// created = true; +// } else { +// trace!(?vmid, "No VM config provided, skipping creation step"); +// } - let vm_info = if vm_config.is_some() { - Some(vm.info().await.map_err(|e| { - error!(error = ?e, "Failed to get VM info after creation"); - ApiError::vm_info(e) - })?) - } else { - None - }; +// let vm_info = if vm_config.is_some() { +// Some(vm.info().await.map_err(|e| { +// error!(error = ?e, "Failed to get VM info after creation"); +// ApiError::vm_info(e) +// })?) +// } else { +// None +// }; - Ok(Json(VmSpawnResponse { - info: vm_info, - booted: query.boot, - created_config: created, - })) -} +// Ok(Json(VmSpawnResponse { +// info: vm_info, +// booted: query.boot, +// created_config: created, +// })) +// } -async fn create_vm_config( - vmid: Path, - Query(query): Query, - Json(vm_config): Json, -) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.create(vm_config, query.boot) - .await - .map_err(ApiError::create_config)?; - Ok(Json(())) -} +// async fn create_vm_config( +// vmid: Path, +// Query(query): Query, +// Json(vm_config): Json, +// ) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.create(vm_config, query.boot) +// .await +// .map_err(ApiError::create_config)?; +// Ok(Json(())) +// } -async fn delete_vm_config(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.delete_config().await.map_err(ApiError::delete_config)?; - Ok(Json(())) -} +// async fn delete_vm_config(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.delete_config().await.map_err(ApiError::delete_config)?; +// Ok(Json(())) +// } -/// Forces a VM to shut down immediately, without giving it a chance to gracefully clean up resources. -/// This is equivalent to pulling the power on a physical machine and may lead to data loss or corruption if the VM is running. -/// -/// The VM process itself will still be running until the VMM detects that the VM has stopped, -/// not fully cleaning up resources. -async fn shutdown_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.shutdown().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Forces a VM to shut down immediately, without giving it a chance to gracefully clean up resources. +// /// This is equivalent to pulling the power on a physical machine and may lead to data loss or corruption if the VM is running. +// /// +// /// The VM process itself will still be running until the VMM detects that the VM has stopped, +// /// not fully cleaning up resources. +// async fn shutdown_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.shutdown().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } -/// Sends an ACPI shutdown signal to the VM, allowing it to gracefully shut down and clean up resources. -/// -/// With the systemd provisioner, this will also fully clean up resources and destroy the VM instance entirely, -/// allowing them to be re-provisioned again on any other node (if running in a cluster) -async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.acpi_power_button().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Sends an ACPI shutdown signal to the VM, allowing it to gracefully shut down and clean up resources. +// /// +// /// With the systemd provisioner, this will also fully clean up resources and destroy the VM instance entirely, +// /// allowing them to be re-provisioned again on any other node (if running in a cluster) +// async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.acpi_power_button().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } -/// Boots a VM that has been created but not yet started. If the VM is already running, this will return an error. -async fn boot_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.boot().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Boots a VM that has been created but not yet started. If the VM is already running, this will return an error. +// async fn boot_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.boot().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } -/// Suspends a running VM, pausing all activity until -/// it is resumed again. -async fn pause_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.pause().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Suspends a running VM, pausing all activity until +// /// it is resumed again. +// async fn pause_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.pause().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } -/// Resumes a paused VM, allowing it to continue running from where it left off. -async fn resume_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.resume().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Resumes a paused VM, allowing it to continue running from where it left off. +// async fn resume_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.resume().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } -/// Destroys a VM, stopping it if it's running and cleaning up resources -async fn destroy_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - vm.destroy().await.map_err(ApiError::vm_info)?; - Ok(Json(())) -} +// /// Destroys a VM, stopping it if it's running and cleaning up resources +// async fn destroy_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// vm.destroy().await.map_err(ApiError::vm_info)?; +// Ok(Json(())) +// } diff --git a/odorobo-agent/src/lib.rs b/odorobo-agent/src/lib.rs index 6f437fc..744de27 100644 --- a/odorobo-agent/src/lib.rs +++ b/odorobo-agent/src/lib.rs @@ -1,4 +1,4 @@ pub mod actor; mod api; mod state; -mod util; \ No newline at end of file +mod util; diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 1546da2..9632165 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -12,12 +12,12 @@ use crate::actor::AgentActor; #[tokio::main] async fn main() -> Result<()> { - odorobo_shared::utils::init()?; + odorobo_shared::utils::init(Some("odorobo_agent"))?; tracing::info!("Starting odorobo-agent..."); // minimal axum server, debug socket - // + // // todo: remove this, here to stub out dead code tokio::task::spawn(async { let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; diff --git a/odorobo-agent/src/state/devices.rs b/odorobo-agent/src/state/devices.rs index f9e4e8e..5558eb8 100644 --- a/odorobo-agent/src/state/devices.rs +++ b/odorobo-agent/src/state/devices.rs @@ -19,12 +19,11 @@ impl VMInstance { .await .wrap_err_with(|| format!("Failed to remove device from VM {}", self.id)) } - + pub async fn add_net(&self, net_config: NetConfig) -> Result { self.conn() .vm_add_net_put(net_config) .await .wrap_err_with(|| format!("Failed to add network device to VM {}", self.id)) } - } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 6e51cd2..e76cab4 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -18,10 +18,12 @@ use std::{ use thiserror::Error; use tracing::{debug, error, info, trace, warn}; -use crate::state::transform::{ConfigTransform, TransformChain}; +use crate::state::{ + provisioning::hooks::HookManager, + transform::{ConfigTransform, TransformChain}, +}; use super::api::{call, call_request}; -use super::transform::apply_builtin_transforms; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; @@ -31,10 +33,24 @@ pub type ConsoleStream = std::fs::File; const DEFAULT_RUNTIME_ROOT_DIR: &str = "/run/odorobo"; const RUNTIME_ROOT_ENV_VAR: &str = "ODOROBO_RUNTIME_DIR"; -#[derive(Debug, Clone)] pub struct VMInstance { pub id: String, pub ch_socket_path: PathBuf, + transformer: TransformChain, + hook_manager: HookManager, + child_process: Option, + /// Pre-transformed VM config, if available + pub vm_config: Option, +} + +impl std::fmt::Debug for VMInstance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VMInstance") + .field("id", &self.id) + .field("ch_socket_path", &self.ch_socket_path) + .field("vm_config", &self.vm_config) + .finish() + } } #[derive(Debug, Error)] @@ -62,24 +78,38 @@ impl From for ChApiError { } impl VMInstance { - pub fn new(id: &str, ch_socket_path: PathBuf) -> Self { + fn new( + id: &str, + ch_socket_path: PathBuf, + transformer: Option, + child_process: Option, + ) -> Self { Self { id: id.to_string(), ch_socket_path, + transformer: transformer.unwrap_or_default(), + hook_manager: HookManager::default(), + child_process, + vm_config: None, } } + /// Get a VM instance by its ID through the filesystem database + /// + /// Not reliable as of 0.2 + #[deprecated(since = "0.2")] pub fn get(vmid: &str) -> Option { Self::list().ok()?.into_iter().find(|i| i.id == vmid) } pub async fn boot(&self) -> Result<()> { // boot hooks - let provisioner = super::provisioning::default_provisioner(); let vm_config = self.info().await?.config; - provisioner.before_boot(self.vm_id(), &vm_config).await?; + self.hook_manager + .before_boot(self.vm_id(), &vm_config) + .await?; self.conn() .boot_vm() @@ -87,7 +117,9 @@ impl VMInstance { .map_err(ChApiError::from) .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; - provisioner.after_boot(self.vm_id(), &vm_config).await?; + self.hook_manager + .after_boot(self.vm_id(), &vm_config) + .await?; Ok(()) } @@ -114,7 +146,8 @@ impl VMInstance { /// - `dest`: the destination URI to migrate to, in the format expected by CH (e.g. "tcp::12345") /// - `local`: if true, indicates that the migration is local (e.g. within the same host, for renaming a VM). This is passed to CH and may affect how the migration is performed. #[tracing::instrument] - pub async fn send_migration(&self, dest: &str, local: bool) -> Result<()> { + #[tracing::instrument] + pub async fn send_migration(&mut self, dest: &str, local: bool) -> Result<()> { let conn = self.conn(); trace!(destination = dest, "Sending migration command to VM"); @@ -177,7 +210,13 @@ impl VMInstance { .map_err(ChApiError::from) .wrap_err(eyre!("Failed to prepare VM for migration {}", vm_id)) { - Ok(_) => info!(vm_id, "Migration receiver completed successfully"), + Ok(_) => { + info!(vm_id, "Migration receiver completed successfully"); + // shut down vm + if let Err(e) = conn.shutdown_vmm().await { + error!(vm_id, error = ?e, "Failed to shut down VM after migration"); + } + } Err(e) => error!(vm_id, error = ?e, "Migration receiver failed"), } }); @@ -305,25 +344,33 @@ impl VMInstance { /// /// Waits for the socket to become available (polls up to ~30 seconds). /// Calls a backend to handle the actual CH process spawning - typically a systemd unit - #[allow(dead_code)] // this thing is used in the REST API - pub async fn spawn(id: &str) -> Result { - info!( - vm_id = id, - socket_path = ?Self::runtime_dir_for(id).join(SOCKET_FILE_NAME), - "Spawning CH process for new VM" - ); - - let provisioner = super::provisioning::default_provisioner(); - // nothing for now, transformers will be used instead? - provisioner.start_instance(id, &VmConfig::default()).await?; - - let instance = Self::new(id, Self::runtime_dir_for(id).join(SOCKET_FILE_NAME)); + #[tracing::instrument(skip_all)] + pub async fn spawn( + id: &str, + vm_config: Option, + transformer: Option, + ) -> Result { + let ch_socket_path = Self::runtime_dir_for(id).join(SOCKET_FILE_NAME); + info!(?ch_socket_path, "Spawning VM"); + // make sure socket path parent exists + if !ch_socket_path.parent().unwrap().exists() { + std::fs::create_dir_all(ch_socket_path.parent().unwrap())?; + } + let ch_process = tokio::process::Command::new("cloud-hypervisor") + .arg("--api-socket") + .arg(&ch_socket_path) + .spawn()?; + let mut instance = Self::new(id, ch_socket_path, transformer, Some(ch_process)); const MAX_ATTEMPTS: u32 = 31; for attempt in 0..MAX_ATTEMPTS { - trace!(vm_id = id, attempt, "Checking if CH socket is available"); + info!(vm_id = id, attempt, "Checking if CH socket is available"); if instance.conn().vmm_ping_get().await.is_ok() { - debug!(vm_id = id, "CH socket available"); + info!(vm_id = id, "CH socket available"); + if let Some(vm_config) = vm_config { + info!(?vm_config, "Creating VM config and booting"); + instance.create_config(vm_config, true).await?; + } return Ok(instance); } @@ -340,7 +387,7 @@ impl VMInstance { } /// Gracefully shutdown the VM and VMM, then clean up runtime state. - pub async fn destroy(&self) -> Result<()> { + pub async fn destroy(&mut self) -> Result<()> { info!( vm_id = self.vm_id(), "Destroying VM instance, shutting down VM and cleaning up runtime state" @@ -361,16 +408,6 @@ impl VMInstance { ); } - let provisioner = super::provisioning::default_provisioner(); - - let vm_config = self - .info() - .await - .ok() - .map(|i| i.config) - .or_else(|| self.load_config().ok()) - .unwrap_or_default(); - if let Ok(()) = self.conn().shutdown_vmm().await { debug!(vm_id = self.vm_id(), "VMM shutdown successfully"); } else { @@ -379,8 +416,22 @@ impl VMInstance { "Failed to shutdown VMM, assuming it is already stopped or unresponsive" ); } + let vm_config = self.vm_config.clone().unwrap_or_default(); + if let Some(mut child) = self.child_process.take() { + trace!("VMM stopped... checking child process"); + let _ = child.start_kill(); + if let Err(err) = child.wait().await { + warn!( + vm_id = self.vm_id(), + ?err, + "Failed to wait for child process, manual cleanup may be required" + ); + } + } - provisioner.stop_instance(self.vm_id(), &vm_config).await?; + self.hook_manager + .after_stop(self.vm_id(), &vm_config) + .await?; if let Err(err) = self.purge_instance_data() { warn!( @@ -396,9 +447,9 @@ impl VMInstance { /// Purge the runtime data for this VM instance. /// /// This removes the runtime directory and all its contents if it exists. - pub fn purge_instance_data(&self) -> Result<()> { - let mut vm_config = self.load_config().unwrap_or_default(); - TransformChain::default().teardown(self.vm_id(), &mut vm_config)?; + pub fn purge_instance_data(&mut self) -> Result<()> { + let mut vm_config = self.vm_config.take().unwrap_or_default(); + self.transformer.teardown(self.vm_id(), &mut vm_config)?; let runtime_dir = self.runtime_dir(); if runtime_dir.exists() { fs::remove_dir_all(runtime_dir).wrap_err(eyre!( @@ -432,19 +483,23 @@ impl VMInstance { /// Applies node-specific transforms, saves config to disk, then: /// 1. Creates the VM via CH API /// 2. Boots the VM (if boot is true) - pub async fn create(&self, config: models::VmConfig, boot: bool) -> Result<()> { + pub async fn create_config(&mut self, config: models::VmConfig, boot: bool) -> Result<()> { trace!(vm_id = self.vm_id(), "Creating VM with provided config"); - let mut config = config; + trace!(vm_id = self.vm_id(), "Applying config transforms"); - apply_builtin_transforms(self.vm_id(), &mut config) - .wrap_err("Failed to apply config transforms")?; + let mut transformed_config = config.clone(); + self.transformer + .transform(self.vm_id(), &mut transformed_config) + .wrap_err(eyre!( + "Failed to apply config transforms for VM {}", + self.vm_id() + ))?; - trace!(vm_id = self.vm_id(), "Saving config to runtime dir"); - self.save_config(&config)?; + // self.save_config(&config)?; trace!(vm_id = self.vm_id(), "Creating VM via CH API"); self.conn() - .create_vm(config) + .create_vm(transformed_config) .await .map_err(ChApiError::from) .wrap_err(eyre!("Failed to create VM {}", self.vm_id()))?; @@ -474,15 +529,6 @@ impl VMInstance { Ok(()) } - /// Call a custom CH API path with an explicit HTTP method. - /// - /// For debugging: lets you hit any CH API endpoint directly. - pub async fn call(&self, method: Method, path: &str, body: Option<&Value>) -> Result<()> { - call(self.ch_socket_path(), method, path, body) - .await - .wrap_err(eyre!("Failed to call {} for {}", path, self.vm_id())) - } - /// Proxy a raw HTTP request to the CH API socket. pub async fn call_request(&self, request: Request) -> Result> { call_request(self.ch_socket_path(), request) @@ -493,6 +539,7 @@ impl VMInstance { /// List running VM instances. /// /// Scans runtime root for directories with valid sockets. + #[deprecated(since = "0.2")] pub fn list() -> Result> { let root = Self::runtime_root(); fs::create_dir_all(&root)?; @@ -506,7 +553,7 @@ impl VMInstance { let id = entry.file_name().to_string_lossy().to_string(); let ch_socket_path = Self::runtime_dir_for(&id).join(SOCKET_FILE_NAME); - Some(Self::new(&id, ch_socket_path)) + Some(Self::new(&id, ch_socket_path, None, None)) }) }) .collect()) diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 8bea936..7f09e7e 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -4,8 +4,8 @@ //! ephemeral VM state like running instances. Persistent state goes in the database. pub mod api; -pub mod instance; pub mod devices; +pub mod instance; pub mod provisioning; pub mod transform; diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index e2f770c..8892aac 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -21,12 +21,8 @@ let output = Command::new("echo") #[derive(RemoteActor)] pub struct VMActor { pub vmid: ulid::Ulid, - /// Pre-transform config, transformed config goes into the CH instance itself - pub vm_config: VmConfig, /// path to the Cloud Hypervisor socket, in /run/odorobo/vms//ch.sock pub vm_instance: VMInstance, - // handle to the Cloud Hypervisor process - process_handle: tokio::process::Child, } impl Actor for VMActor { @@ -36,33 +32,30 @@ impl Actor for VMActor { #[tracing::instrument(skip_all)] async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { - let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); - - let vminstance = VMInstance::new(&vmid.to_string(), ch_sock_path.clone()); - - tracing::warn!("no-op"); - // spawn CH instance - // this probably is not an ideal way to do this, but we want a minimal thing - // so let's spawn CH as a child - // - // ...or we go back to that systemd way - - // ownership quirk - // let value = ch_sock_path.clone(); - let ch_process = tokio::process::Command::new("cloud-hypervisor") - .arg("--api-socket") - .arg(&ch_sock_path) - .spawn()?; + // let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); + + // // no transform chain + + // tracing::warn!("no-op"); + // // spawn CH instance + // // this probably is not an ideal way to do this, but we want a minimal thing + // // so let's spawn CH as a child + // // + // // ...or we go back to that systemd way + + // // ownership quirk + // // let value = ch_sock_path.clone(); + // let ch_process = tokio::process::Command::new("cloud-hypervisor") + // .arg("--api-socket") + // .arg(&ch_sock_path) + // .spawn()?; // tokio::spawn(async move { - // Ok::<_, Report>(ch_process) - // }); + let vminstance = VMInstance::spawn(&vmid.to_string(), Some(vm_config), None).await?; Ok(Self { vmid, - vm_config, vm_instance: vminstance, - process_handle: ch_process, }) } @@ -88,8 +81,7 @@ impl Actor for VMActor { self.vm_instance.destroy().await?; - let res = self.process_handle.wait().await; - info!(vmid = %self.vmid, ?res, "VM process exited"); + // info!(vmid = %self.vmid, ?res, "VM process exited"); Ok(()) } diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs index 552421d..5e8411d 100644 --- a/odorobo-agent/src/state/provisioning/mod.rs +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -2,9 +2,9 @@ //! //! Provides helper functions that calls the necessary hooks and various methods to start a //! Cloud Hypervisor process for a given instance -mod hooks; -mod systemd; pub mod actor; +pub mod hooks; +mod systemd; use cloud_hypervisor_client::models::VmConfig; use stable_eyre::{Result, eyre::Context}; use tracing::info; diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index d6fb15e..92dcbfa 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -11,8 +11,8 @@ pub trait ConfigTransform: Send + Sync { } } -mod console; -mod path_verify; +pub mod console; +pub mod path_verify; pub mod storage; pub use console::ConsoleTransform; pub use path_verify::PathVerify; @@ -61,7 +61,3 @@ impl Default for TransformChain { .add(PathVerify) } } - -pub fn apply_builtin_transforms(vmid: &str, config: &mut VmConfig) -> Result<()> { - TransformChain::default().transform(vmid, config) -} diff --git a/odorobo-agent/src/util.rs b/odorobo-agent/src/util.rs index 610f4be..5b21398 100644 --- a/odorobo-agent/src/util.rs +++ b/odorobo-agent/src/util.rs @@ -1,5 +1,5 @@ -use zbus::Connection; use stable_eyre::{Result, eyre::Context}; +use zbus::Connection; /// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name pub fn systemd_instance_unit_name(vmid: &str) -> String { format!("odorobo-ch@{vmid}.service") diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index 9bcaad8..7a49bc4 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] kameo = { version = "0.19.2", features = ["remote"] } axum = { version = "0.8.8", features = ["tracing", "macros"] } +cloud-hypervisor-client = { workspace = true } aide = { version = "0.15.1", features = [ "axum", "axum-json", diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index ae8be79..e514b73 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -1,30 +1,79 @@ +use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; -use tracing::{error, info, warn}; -//use odorobo_shared::odorobo::server_actor::ServerActor; -use odorobo_agent::actor::AgentActor; -use odorobo_shared::messages::create_vm::*; -use odorobo_shared::messages::debug::PanicAgent; +use odorobo_shared::messages::create_vm::{CreateVM, CreateVMReply}; use stable_eyre::{Report, Result}; + +use crate::api::types::CreateVMRequest; + +use super::scheduler_actor::SchedulerActor; + const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: mak /// HTTP REST API service #[derive(RemoteActor)] -pub struct HTTPActor; +pub struct HTTPActor { + pub scheduler: ActorRef, +} + +impl HTTPActor { + pub fn create_vm_message(request: CreateVMRequest) -> CreateVM { + CreateVM { + vm_id: request.data.id, + config: VmConfig { + cpus: Some(CpusConfig { + boot_vcpus: request.data.vcpus as i32, + max_vcpus: request + .data + .max_vcpus + .map(|v| v as i32) + .unwrap_or(request.data.vcpus as i32), + ..Default::default() + }), + memory: Some(MemoryConfig { + size: request.data.memory.as_u64() as i64, + ..Default::default() + }), + payload: PayloadConfig { + kernel: Some(request.data.image), + ..Default::default() + }, + ..Default::default() + }, + } + } +} impl Actor for HTTPActor { - type Args = (); + type Args = ActorRef; type Error = Report; - async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + async fn on_start( + scheduler: Self::Args, + actor_ref: ActorRef, + ) -> Result { // run the HTTP API tokio::spawn(async move { tracing::info!("Starting HTTP server on {EXTERNAL_HTTP_URL}"); let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) .await .unwrap(); - axum::serve(listener, crate::api::build()).await.unwrap(); + axum::serve(listener, crate::api::build(actor_ref)) + .await + .unwrap(); }); - Ok(Self) + Ok(Self { scheduler }) + } +} + +impl Message for HTTPActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: CreateVM, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.scheduler.ask(msg).await?) } } diff --git a/odorobo-manager/src/actors/mod.rs b/odorobo-manager/src/actors/mod.rs index 89ee40c..8a4c193 100644 --- a/odorobo-manager/src/actors/mod.rs +++ b/odorobo-manager/src/actors/mod.rs @@ -1,4 +1,4 @@ pub mod http_actor; +pub mod ip_management_actor; pub mod scheduler_actor; pub mod serial_terminal_websocket_actor; -pub mod ip_management_actor; \ No newline at end of file diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index a0ef53a..b6c679b 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -1,62 +1,121 @@ +use std::ops::ControlFlow; + use kameo::prelude::*; -use tower_http::classify::SharedClassifier; -use tracing::{error, info, warn}; -//use odorobo_shared::odorobo::server_actor::ServerActor; use odorobo_agent::actor::AgentActor; use odorobo_shared::messages::create_vm::*; -use odorobo_shared::messages::debug::PanicAgent; -use stable_eyre::{Report, Result}; +use odorobo_shared::messages::{Ping, Pong}; +use stable_eyre::{Report, Result, eyre::eyre}; +use tracing::{info, warn}; #[derive(RemoteActor)] -pub struct SchedulerActor; - -impl Actor for SchedulerActor { - type Args = (); - type Error = Report; - - async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { - let peer_id = actor_ref.id().peer_id().unwrap().clone(); - - info!("Actor started! Scheduler peer id: {peer_id}"); - - let mut agent_actor: Option> = None; +pub struct SchedulerActor { + pub agent_actor: Option>, +} +impl SchedulerActor { + async fn lookup_agent( + actor_ref: &ActorRef, + ) -> Result, Report> { loop { let agent_actor_option = RemoteActorRef::::lookup("agent").await?; - let Some(agent_actor_in_loop) = agent_actor_option else { + let Some(agent_actor) = agent_actor_option else { + warn!("No agent actor currently registered, retrying lookup"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; continue; }; - agent_actor = Some(agent_actor_in_loop); - break; + let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); + info!("Using agent actor peer id: {agent_actor_peer_id}"); + + // remotely link actor, on link death it will be automatically unlinked + actor_ref.link_remote(&agent_actor).await?; + + return Ok(agent_actor); } + } - let agent_actor = agent_actor.unwrap(); + async fn ensure_agent( + &mut self, + actor_ref: &ActorRef, + ) -> Result, Report> { + if let Some(agent_actor) = &self.agent_actor { + return Ok(agent_actor.clone()); + } - let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); + let new_agent = Self::lookup_agent(actor_ref).await?; + self.agent_actor = Some(new_agent.clone()); + Ok(new_agent) + } +} + +impl Actor for SchedulerActor { + type Args = (); + type Error = Report; - info!("Agent actor peer id: {agent_actor_peer_id}"); + async fn on_start(_state: Self::Args, actor_ref: ActorRef) -> Result { + let peer_id = actor_ref.id().peer_id().unwrap().clone(); + info!("Actor started! Scheduler peer id: {peer_id}"); + let agent_actor = Self::lookup_agent(&actor_ref).await?; - // let reply = agent_actor - // .ask(&CreateVM { - // vm_id: Default::default(), - // config: Default::default(), - // }) - // .await?; + Ok(Self { + agent_actor: Some(agent_actor), + }) + } - // info!(?reply, "Created VM Reply"); + async fn on_link_died( + &mut self, + actor_ref: WeakActorRef, + id: ActorId, + reason: ActorStopReason, + ) -> Result, Self::Error> { + warn!("Linked actor {id:?} died with reason {reason:?}"); - // tokio::time::sleep(std::time::Duration::from_secs(10)).await; + self.agent_actor = None; - // warn!("Panicking Agent"); + let Some(actor_ref) = actor_ref.upgrade() else { + return Ok(ControlFlow::Break(ActorStopReason::Killed)); + }; - // agent_actor.tell(&PanicAgent).send()?; + let new_agent = Self::lookup_agent(&actor_ref).await?; + self.agent_actor = Some(new_agent); + + Ok(ControlFlow::Continue(())) + } +} + +impl Message for SchedulerActor { + type Reply = Result; + + async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { + let actor_ref = ctx.actor_ref(); + + let first_agent = self.ensure_agent(&actor_ref).await?; + match first_agent.ask(&msg).await { + Ok(reply) => Ok(reply), + Err(first_err) => { + warn!( + "CreateVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" + ); + self.agent_actor = None; + + let retry_agent = self.ensure_agent(&actor_ref).await?; + retry_agent.ask(&msg).await.map_err(|retry_err| { + eyre!( + "failed to forward CreateVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" + ) + }) + } + } + } +} - // error!("Agent has been panicked."); +impl Message for SchedulerActor { + type Reply = Pong; - Ok(Self) + async fn handle(&mut self, _msg: Ping, _ctx: &mut Context) -> Self::Reply { + Pong } } diff --git a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs index d373b5c..f8b95e5 100644 --- a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs +++ b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs @@ -1,7 +1,6 @@ use kameo::prelude::*; -use tracing::{error, info, warn}; use stable_eyre::{Report, Result}; - +use tracing::{error, info, warn}; /// HTTP REST API service #[derive(RemoteActor)] diff --git a/odorobo-manager/src/api/mod.rs b/odorobo-manager/src/api/mod.rs index 0206e44..2130f04 100644 --- a/odorobo-manager/src/api/mod.rs +++ b/odorobo-manager/src/api/mod.rs @@ -8,9 +8,12 @@ use aide::{ swagger::Swagger, }; use axum::{Extension, Json, Router}; +use kameo::actor::ActorRef; + +use crate::actors::http_actor::HTTPActor; /// Build the full app: finalizes the OpenAPI spec and attaches it as an extension. -pub fn build() -> Router { +pub fn build(state: ActorRef) -> Router { aide::generate::on_error(|error| { tracing::warn!("aide schema gen error: {error}"); }); @@ -24,7 +27,9 @@ pub fn build() -> Router { ..Default::default() }; - router().finish_api(&mut openapi).layer(Extension(openapi)) + router::<()>(state) + .finish_api(&mut openapi) + .layer(Extension(openapi)) } // todo: error handling @@ -34,14 +39,18 @@ pub fn build() -> Router { // - cappy /// Main router for the API -fn router() -> ApiRouter { - ApiRouter::new() +fn router(state: ActorRef) -> ApiRouter +where + S: Clone + Send + Sync + 'static, +{ + ApiRouter::>::new() .api_route("/health", get(health)) .api_route("/swagger", Swagger::new("/openapi.json").axum_route()) .api_route("/openapi.json", get(serve_api)) - .nest("/nodes", nodes::router()) - .nest("/vms", vms::router()) - .nest("/volumes", volumes::router()) + .nest("/nodes", nodes::router().with_state(state.clone())) + .nest("/vms", vms::router().with_state(state.clone())) + .nest("/volumes", volumes::router().with_state(state.clone())) + .with_state(state) } /// Serve the OpenAPI spec as JSON diff --git a/odorobo-manager/src/api/nodes.rs b/odorobo-manager/src/api/nodes.rs index 95ce6d3..19822c5 100644 --- a/odorobo-manager/src/api/nodes.rs +++ b/odorobo-manager/src/api/nodes.rs @@ -1,23 +1,31 @@ -//! Compute node management API handlers.//! VM management API handlers. -use crate::api::types::{Node, VmId}; +//! Compute node management API handlers. +use crate::{actors::http_actor::HTTPActor, api::types::Node}; use aide::axum::{ ApiRouter, IntoApiResponse, routing::{get, put}, }; -use axum::{Json, extract::Path}; -pub fn router() -> ApiRouter { +use axum::{ + Json, + extract::{Path, State}, +}; +use kameo::actor::ActorRef; + +pub fn router() -> ApiRouter> { ApiRouter::new() .api_route("/drain", put(drain)) .api_route("/{nodeid}", get(node_info)) } /// Drain a node of all VMs, migrating them away or shutting them down as needed. This is used for maintenance mode. -async fn drain() -> impl IntoApiResponse { +async fn drain(State(state): State>) -> impl IntoApiResponse { // stub Json("Draining...".to_string()) } /// Get detailed information about a specific node, including its current VMs and resource usage. -async fn node_info(Path(node_id): Path) -> impl IntoApiResponse { +async fn node_info( + State(state): State>, + Path(node_id): Path, +) -> impl IntoApiResponse { // stub, Json(Node::default()) } diff --git a/odorobo-manager/src/api/types.rs b/odorobo-manager/src/api/types.rs index 421e96c..c96f309 100644 --- a/odorobo-manager/src/api/types.rs +++ b/odorobo-manager/src/api/types.rs @@ -1,5 +1,6 @@ use aide::OperationIo; use bytesize::ByteSize; +use cloud_hypervisor_client::models::VmConfig; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ulid::Ulid; @@ -42,7 +43,7 @@ pub struct VmId(#[schemars(with = "String")] pub Ulid); #[derive(Deserialize, JsonSchema)] pub struct VolumeId(#[schemars(with = "String")] pub Ulid); -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct CreateVMRequest { /// Data of the VM to create pub data: VMData, @@ -50,7 +51,20 @@ pub struct CreateVMRequest { pub boot: bool, } -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +/// An internal, debug-only request for creating a VM. +/// +/// please don't use this in production, this is for debugging +/// +/// PUT /vms/ +#[derive(Serialize, Deserialize, Debug, OperationIo, Default, Clone)] +pub struct DebugCreateVMRequest { + /// Data of the VM to create + pub vm_config: VmConfig, + /// Whether to boot the VM immediately after creation + pub boot: bool, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct VMData { /// VM ID. This is a ULID string. #[schemars(with = "String")] @@ -117,7 +131,7 @@ pub struct VMInfo { pub status: VMStatus, } -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct Volume { /// Volume ID. This is a ULID string. #[schemars(with = "String")] diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index ea787d7..6162096 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -1,31 +1,71 @@ //! VM management API handlers. -use crate::api::types::{CreateVMRequest, UpdateVMRequest, VMInfo, VmId}; +use crate::{ + actors::http_actor::HTTPActor, + api::types::{CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMInfo, VmId}, +}; use aide::axum::{ ApiRouter, IntoApiResponse, - routing::{delete, get, patch, post}, + routing::{delete, get, patch, post, put}, +}; +use axum::{ + Json, + extract::{Path, State}, }; -use axum::{Json, extract::Path}; +use kameo::actor::ActorRef; -pub fn router() -> ApiRouter { +pub fn router() -> ApiRouter> { ApiRouter::new() .api_route("/", post(create_vm)) + // undocumented debug route, do not use in prod + .route("/", axum::routing::put(debug_create_vm)) .api_route("/{vmid}", get(vm_info)) .api_route("/{vmid}", patch(update_vm)) .api_route("/{vmid}", delete(delete_vm)) } /// Get detailed information about a specific VM -async fn vm_info(Path(VmId(vmid)): Path) -> impl IntoApiResponse { +async fn vm_info( + State(state): State>, + Path(VmId(vmid)): Path, +) -> impl IntoApiResponse { // stub, Json(VMInfo::default()) } -async fn create_vm(Json(request): Json) -> impl IntoApiResponse { - // stub +async fn create_vm( + State(state): State>, + Json(request): Json, +) -> impl IntoApiResponse { + let vm_data = request.data.clone(); + let message = HTTPActor::create_vm_message(request); + + let _reply = state.ask(message).await.unwrap(); + + Json(VMInfo { + data: vm_data, + node: None, + status: crate::api::types::VMStatus::Provisioning, + }) +} + +async fn debug_create_vm( + State(state): State>, + Json(request): Json, +) -> impl IntoApiResponse { + let message = odorobo_shared::messages::create_vm::CreateVM { + vm_id: ulid::Ulid::new(), + config: request.vm_config, + }; + + let _reply = state.ask(message).await.unwrap(); + Json(VMInfo::default()) } -async fn delete_vm(Path(VmId(vmid)): Path) -> impl IntoApiResponse { +async fn delete_vm( + State(state): State>, + Path(VmId(vmid)): Path, +) -> impl IntoApiResponse { // stub } @@ -33,6 +73,7 @@ async fn delete_vm(Path(VmId(vmid)): Path) -> impl IntoApiResponse { /// /// todo: make new schema for update request that allows partial updates async fn update_vm( + State(state): State>, Path(VmId(vmid)): Path, Json(request): Json, ) -> impl IntoApiResponse { diff --git a/odorobo-manager/src/api/volumes.rs b/odorobo-manager/src/api/volumes.rs index 121fa1e..1209ea6 100644 --- a/odorobo-manager/src/api/volumes.rs +++ b/odorobo-manager/src/api/volumes.rs @@ -1,12 +1,19 @@ //! Volume management API handlers. -use crate::api::types::{CreateVolumeRequest, Volume, VolumeId, VolumeInfo}; +use crate::{ + actors::http_actor::HTTPActor, + api::types::{CreateVolumeRequest, Volume, VolumeId, VolumeInfo}, +}; use aide::axum::{ ApiRouter, IntoApiResponse, routing::{delete, get, patch, put}, }; -use axum::{Json, extract::Path}; +use axum::{ + Json, + extract::{Path, State}, +}; +use kameo::actor::ActorRef; -pub fn router() -> ApiRouter { +pub fn router() -> ApiRouter> { ApiRouter::new() .api_route("/", put(create_volume)) .api_route("/{volid}", get(volume_info)) @@ -21,17 +28,24 @@ async fn volume_info(Path(VolumeId(volid)): Path) -> impl IntoApiRespo } /// Create a new volume with the specified parameters -async fn create_volume(Json(request): Json) -> impl IntoApiResponse { +async fn create_volume( + State(state): State>, + Json(request): Json, +) -> impl IntoApiResponse { // stub Json(VolumeInfo::default()) } /// Delete an existing volume by ID -async fn delete_volume(Path(VolumeId(volid)): Path) -> impl IntoApiResponse { +async fn delete_volume( + State(state): State>, + Path(VolumeId(volid)): Path, +) -> impl IntoApiResponse { // stub } /// Resize an existing volume to a new size async fn resize_volume( + State(state): State>, Path(VolumeId(volid)): Path, Json(request): Json, ) -> impl IntoApiResponse { diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs index dfc5c83..74f9bdd 100644 --- a/odorobo-manager/src/lib.rs +++ b/odorobo-manager/src/lib.rs @@ -1,2 +1,2 @@ +pub mod actors; pub mod api; -pub mod actors; \ No newline at end of file diff --git a/odorobo-manager/src/main.rs b/odorobo-manager/src/main.rs index b6f3437..76b7756 100644 --- a/odorobo-manager/src/main.rs +++ b/odorobo-manager/src/main.rs @@ -31,19 +31,18 @@ struct Config { async fn main() -> Result<()> { let _local_peer_id = connect_to_swarm()?; - odorobo_shared::utils::init()?; + odorobo_shared::utils::init(Some("odorobo_manager"))?; dotenvy::dotenv().ok(); info!("Starting odorobo-manager"); - let actor_ref = SchedulerActor::spawn(()); - let http_actor = HTTPActor::spawn(()); + let scheduler_actor = SchedulerActor::spawn(()); + let http_actor = HTTPActor::spawn(scheduler_actor.clone()); - actor_ref.register("scheduler").await?; + scheduler_actor.register("scheduler").await?; http_actor.register("apiserver").await?; - tokio::join!( - actor_ref.wait_for_shutdown(), + scheduler_actor.wait_for_shutdown(), http_actor.wait_for_shutdown(), ); diff --git a/odorobo-shared/src/error.rs b/odorobo-shared/src/error.rs index 890a1c4..1d238b4 100644 --- a/odorobo-shared/src/error.rs +++ b/odorobo-shared/src/error.rs @@ -1,8 +1,7 @@ - use thiserror::Error; #[derive(Error, Debug)] pub enum OdoroboError { #[error("")] - Foobar + Foobar, } diff --git a/odorobo-shared/src/messages/create_vm.rs b/odorobo-shared/src/messages/create_vm.rs index 049c0f4..cf173f9 100644 --- a/odorobo-shared/src/messages/create_vm.rs +++ b/odorobo-shared/src/messages/create_vm.rs @@ -27,3 +27,29 @@ pub struct CreateVM { pub struct CreateVMReply { pub config: Option, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteVM { + pub vm_id: Ulid, +} + +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct DeleteVMReply; + + +#[derive(Serialize, Deserialize, Debug)] +pub struct ShutdownVM { + pub vm_id: Ulid, +} + +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct ShutdownVMReply; + +#[derive(Serialize, Deserialize, Debug)] +pub struct AgentListVMs; + +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct AgentListVMsReply { + // list VMs + pub vms: Vec, +} diff --git a/odorobo-shared/src/messages/mod.rs b/odorobo-shared/src/messages/mod.rs index da6892a..c804d29 100644 --- a/odorobo-shared/src/messages/mod.rs +++ b/odorobo-shared/src/messages/mod.rs @@ -1,3 +1,14 @@ +use kameo::prelude::*; +use serde::{Deserialize, Serialize}; + pub mod create_vm; pub mod debug; pub mod server_status; + +/// A request to ping the server (keepalive) +#[derive(Serialize, Deserialize, Debug)] +pub struct Ping; + +/// Reply to a [`Ping`] request. +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct Pong; diff --git a/odorobo-shared/src/messages/server_status.rs b/odorobo-shared/src/messages/server_status.rs index fa61286..b6f99d5 100644 --- a/odorobo-shared/src/messages/server_status.rs +++ b/odorobo-shared/src/messages/server_status.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use kameo::Reply; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct GetServerStatus; @@ -7,5 +7,5 @@ pub struct GetServerStatus; #[derive(Serialize, Deserialize, Reply, Debug)] pub struct ServerStatus { pub vcpus: u32, - pub ram: u32 -} \ No newline at end of file + pub ram: u32, +} diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index 1044c88..807a134 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -2,7 +2,7 @@ use stable_eyre::Result; // use tracing:: use tracing_subscriber::{EnvFilter, filter::LevelFilter, fmt, prelude::*}; -pub fn env_filter() -> EnvFilter { +pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); let base = EnvFilter::builder() @@ -10,18 +10,34 @@ pub fn env_filter() -> EnvFilter { .parse_lossy(&env); #[cfg(debug_assertions)] - let base = base.add_directive("odorobo*=trace".parse().unwrap()); + let base = { + let base = if let Some(debug_target) = debug_target { + base.add_directive(format!("{debug_target}=trace").parse().unwrap()) + } else { + base + }; + + base.add_directive( + format!("{}=trace", env!("CARGO_PKG_NAME").replace('-', "_")) + .parse() + .unwrap(), + ) + }; base } -pub fn init() -> Result<()> { +pub fn init(debug_target: Option<&str>) -> Result<()> { stable_eyre::install()?; tracing_subscriber::fmt() - .with_env_filter(env_filter()) + .with_env_filter(env_filter(debug_target)) .with_file(true) .with_line_number(true) .init(); Ok(()) } + +pub fn init_default() -> Result<()> { + init(None) +} diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index a87d098..8035d9c 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -2,34 +2,22 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; use reqwest::{Client, Response}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use stable_eyre::Result; #[derive(Parser)] #[command( name = "odoroboctl", - about = "Command-line interface for odorobo agent" + about = "Command-line interface for odorobo manager" )] pub struct Cli { - /// Address of the odorobo agent API server, e.g. "http://localhost:8890" + /// Address of the odorobo manager scheduler API server, e.g. "http://localhost:3000" #[arg( long, - env = "ODOROBO_AGENT_ADDR", - default_value = "http://localhost:8890" + env = "ODOROBO_MANAGER_ADDR", + default_value = "http://localhost:3000" )] - pub agent_addr: String, - - /// Directory for storing runtime files for odorobo agent, such as instance and VM state files. - #[arg( - long, - env = "ODOROBO_AGENT_RUNTIME_DIR", - default_value = "/run/odorobo" - )] - pub agent_runtime_dir: String, - - /// Path to the ch-remote binary, used for passing through with `odoroboctl ch-remote ...` - #[arg(long, env = "CH_REMOTE_PATH", default_value = "ch-remote")] - pub ch_remote_path: String, + pub manager_addr: String, #[command(subcommand)] pub command: Command, @@ -37,32 +25,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Command { - /// List all VMs - List, - /// Get VM information - Info { vmid: String }, - /// Ping VM (check if VMM is running) - Ping { vmid: String }, - /// Spawn (create and start) a new VM - Spawn { - /// VM ID - vmid: String, - - /// Path to the VM config file - /// (in Cloud Hypervisor JSON format) - config: Option, - - /// Boot the VM after creation - #[arg(long)] - boot: bool, - }, - - /// Create a configuration for a new VM, + /// Create a VM via the scheduler debug endpoint, /// optionally also booting it immediately after creation (if `--boot` is specified). Create { - /// VM ID - vmid: String, - /// Path to the VM config file /// (in Cloud Hypervisor JSON format) config: PathBuf, @@ -71,46 +36,12 @@ pub enum Command { #[arg(long)] boot: bool, }, - /// Delete a VM configuration (without deleting the VM instance itself) - Delete { vmid: String }, - /// Boot a VM - Boot { vmid: String }, - /// Pause a running VM - Pause { vmid: String }, - /// Resume a paused VM - Resume { vmid: String }, - /// Shutdown a VM gracefully - Shutdown { vmid: String }, - /// Send ACPI power button event to VM - AcpiShutdown { vmid: String }, - /// Destroy (delete) a VM - Destroy { vmid: String }, - /// Pass-through to Cloud Hypervisor CLI - ChRemote { - vmid: String, - /// Any arbitrary args to pass through to ch-remote, e.g. `odoroboctl ch-remote myvm ping` - #[arg(last = true)] - args: Vec, - }, - - /// Migrate a VM to another host - MigrateSend { - /// VM ID of the source VM to migrate - vmid: String, - - /// Address of the destination host to migrate to - destination: String, - - /// Indicate that this is a local migration (e.g. within the same host) - #[arg(long)] - local: bool, - }, +} - /// Prepare a VM to receive a migration from another host - MigrateReceive { - /// VM ID of the destination VM to prepare for migration - vmid: String, - }, +#[derive(Debug, Serialize)] +struct DebugCreateVMRequest { + vm_config: serde_json::Value, + boot: bool, } // the fields are used using debug printing, so we allow dead code warnings @@ -137,16 +68,6 @@ async fn print_api_error(response: Response) -> Result<()> { Ok(()) } -async fn print_text_response(response: Response) -> Result<()> { - if response.status().is_success() { - println!("{}", response.text().await?); - } else { - print_api_error(response).await?; - } - - Ok(()) -} - async fn print_message_response(response: Response, success_message: &str) -> Result<()> { if response.status().is_success() { println!("{success_message}"); @@ -159,135 +80,17 @@ async fn print_message_response(response: Response, success_message: &str) -> Re pub async fn run_command(cli: Cli) -> Result<()> { let client = Client::new(); - let base_url = format!("{}/vms", cli.agent_addr); + let base_url = cli.manager_addr; match cli.command { - Command::List => { - let response = client.get(&base_url).send().await?; - - if response.status().is_success() { - let vms: Vec = response.json().await?; - println!("VMs:"); - for vm in vms { - println!("- {}", vm); - } - } else { - print_api_error(response).await?; - } - } - - Command::Info { vmid } => { - let url = format!("{}/{}", base_url, vmid); - let response = client.get(&url).send().await?; - print_text_response(response).await?; - } - - Command::Ping { vmid } => { - let url = format!("{}/{}/ping", base_url, vmid); - let response = client.get(&url).send().await?; - print_text_response(response).await?; - } - - Command::Spawn { vmid, config, boot } => { - let url = format!("{base_url}/{vmid}?boot={boot}"); - - let response = if let Some(config_path) = config { - let config_content = std::fs::read_to_string(&config_path)?; - client - .put(&url) - .header("Content-Type", "application/json") - .body(config_content) - .send() - .await? - } else { - client.put(&url).send().await? - }; - - print_text_response(response).await?; - } - - Command::Create { vmid, config, boot } => { - let url = format!("{base_url}/{vmid}/config?boot={boot}"); - let config_content = std::fs::read_to_string(&config)?; - let response = client - .put(&url) - .header("Content-Type", "application/json") - .body(config_content) - .send() - .await?; - - print_message_response(response, "VM created successfully").await?; - } - - Command::Delete { vmid } => { - let url = format!("{base_url}/{vmid}/config"); - let response = client.delete(&url).send().await?; - print_message_response(response, "VM configuration deleted successfully").await?; - } - - Command::Boot { vmid } => { - let url = format!("{}/{}/boot", base_url, vmid); - let response = client.put(&url).send().await?; - print_message_response(response, "VM booted successfully").await?; - } - - Command::Pause { vmid } => { - let url = format!("{}/{}/pause", base_url, vmid); - let response = client.put(&url).send().await?; - print_message_response(response, "VM paused successfully").await?; - } - - Command::Resume { vmid } => { - let url = format!("{}/{}/resume", base_url, vmid); - let response = client.put(&url).send().await?; - print_message_response(response, "VM resumed successfully").await?; - } - - Command::Shutdown { vmid } => { - let url = format!("{}/{}/shutdown", base_url, vmid); - let response = client.put(&url).send().await?; - print_message_response(response, "VM shutdown initiated").await?; - } - - Command::AcpiShutdown { vmid } => { - let url = format!("{}/{}/acpi_shutdown", base_url, vmid); - let response = client.put(&url).send().await?; - print_message_response(response, "ACPI shutdown signal sent").await?; - } - - Command::Destroy { vmid } => { - let url = format!("{}/{}", base_url, vmid); - let response = client.delete(&url).send().await?; - print_message_response(response, "VM destroyed successfully").await?; - } - - Command::MigrateSend { vmid, destination, local } => { - let url = format!("{base_url}/{vmid}/migrate/send"); - let body = serde_json::json!({ "destination": destination, "local": local }); + Command::Create { config, boot } => { + let url = format!("{}/vms", base_url); + let vm_config = + serde_json::from_str::(&std::fs::read_to_string(&config)?)?; + let body = DebugCreateVMRequest { vm_config, boot }; let response = client.put(&url).json(&body).send().await?; - print_text_response(response).await?; - } - - Command::MigrateReceive { vmid } => { - let url = format!("{base_url}/{vmid}/migrate/receive"); - let response = client.put(&url).send().await?; - print_text_response(response).await?; - } - - Command::ChRemote { vmid, args } => { - let runtime_dir = cli.agent_runtime_dir; - let socket_id = format!("{runtime_dir}/vms/{vmid}/ch.sock"); - let mut child = std::process::Command::new(&cli.ch_remote_path) - .arg("--api-socket") - .arg(socket_id) - .args(args) - .stdin(std::process::Stdio::inherit()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn()?; - let status = child.wait()?; - std::process::exit(status.code().unwrap_or(1)); + print_message_response(response, "VM create request sent successfully").await?; } } From df6b1c1a76a33acec7e6496352fa3a2d2cba7257 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 02:42:53 +0700 Subject: [PATCH 071/189] implement agent api routes --- odorobo-manager/src/actors/http_actor.rs | 41 ++++++++- odorobo-manager/src/actors/scheduler_actor.rs | 86 +++++++++++++++++++ odorobo-manager/src/api/types.rs | 14 ++- odorobo-manager/src/api/vms.rs | 28 +++++- odoroboctl/src/cli.rs | 49 +++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index e514b73..4cc0a1e 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -1,6 +1,9 @@ use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; -use odorobo_shared::messages::create_vm::{CreateVM, CreateVMReply}; +use odorobo_shared::messages::create_vm::{ + AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, + ShutdownVMReply, +}; use stable_eyre::{Report, Result}; use crate::api::types::CreateVMRequest; @@ -77,3 +80,39 @@ impl Message for HTTPActor { Ok(self.scheduler.ask(msg).await?) } } + +impl Message for HTTPActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: DeleteVM, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.scheduler.ask(msg).await?) + } +} + +impl Message for HTTPActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: ShutdownVM, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.scheduler.ask(msg).await?) + } +} + +impl Message for HTTPActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: AgentListVMs, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.scheduler.ask(msg).await?) + } +} diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index b6c679b..640bcf2 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -112,6 +112,92 @@ impl Message for SchedulerActor { } } +impl Message for SchedulerActor { + type Reply = Result; + + async fn handle(&mut self, msg: DeleteVM, ctx: &mut Context) -> Self::Reply { + let actor_ref = ctx.actor_ref(); + + let first_agent = self.ensure_agent(&actor_ref).await?; + match first_agent.ask(&msg).await { + Ok(reply) => Ok(reply), + Err(first_err) => { + warn!( + "DeleteVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" + ); + self.agent_actor = None; + + let retry_agent = self.ensure_agent(&actor_ref).await?; + retry_agent.ask(&msg).await.map_err(|retry_err| { + eyre!( + "failed to forward DeleteVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" + ) + }) + } + } + } +} + +impl Message for SchedulerActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: ShutdownVM, + ctx: &mut Context, + ) -> Self::Reply { + let actor_ref = ctx.actor_ref(); + + let first_agent = self.ensure_agent(&actor_ref).await?; + match first_agent.ask(&msg).await { + Ok(reply) => Ok(reply), + Err(first_err) => { + warn!( + "ShutdownVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" + ); + self.agent_actor = None; + + let retry_agent = self.ensure_agent(&actor_ref).await?; + retry_agent.ask(&msg).await.map_err(|retry_err| { + eyre!( + "failed to forward ShutdownVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" + ) + }) + } + } + } +} + +impl Message for SchedulerActor { + type Reply = Result; + + async fn handle( + &mut self, + msg: AgentListVMs, + ctx: &mut Context, + ) -> Self::Reply { + let actor_ref = ctx.actor_ref(); + + let first_agent = self.ensure_agent(&actor_ref).await?; + match first_agent.ask(&msg).await { + Ok(reply) => Ok(reply), + Err(first_err) => { + warn!( + "AgentListVMs forwarding failed, clearing cached agent and retrying lookup: {first_err}" + ); + self.agent_actor = None; + + let retry_agent = self.ensure_agent(&actor_ref).await?; + retry_agent.ask(&msg).await.map_err(|retry_err| { + eyre!( + "failed to forward AgentListVMs to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" + ) + }) + } + } + } +} + impl Message for SchedulerActor { type Reply = Pong; diff --git a/odorobo-manager/src/api/types.rs b/odorobo-manager/src/api/types.rs index c96f309..0ed0b47 100644 --- a/odorobo-manager/src/api/types.rs +++ b/odorobo-manager/src/api/types.rs @@ -36,11 +36,11 @@ mod opt_bytesize_as_u64 { // Newtype so aide can generate a path parameter schema for Ulid. /// VM ID, in the format of ULID -#[derive(Deserialize, JsonSchema, OperationIo)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, OperationIo, Default, Clone)] pub struct VmId(#[schemars(with = "String")] pub Ulid); /// Volume ID, in the format of ULID -#[derive(Deserialize, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, OperationIo, Default, Clone)] pub struct VolumeId(#[schemars(with = "String")] pub Ulid); #[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] @@ -52,9 +52,9 @@ pub struct CreateVMRequest { } /// An internal, debug-only request for creating a VM. -/// +/// /// please don't use this in production, this is for debugging -/// +/// /// PUT /vms/ #[derive(Serialize, Deserialize, Debug, OperationIo, Default, Clone)] pub struct DebugCreateVMRequest { @@ -131,6 +131,12 @@ pub struct VMInfo { pub status: VMStatus, } +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct VMListResponse { + /// List of VMs currently known by the agent. + pub vms: Vec, +} + #[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct Volume { /// Volume ID. This is a ULID string. diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index 6162096..3d5e1da 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -1,7 +1,9 @@ //! VM management API handlers. use crate::{ actors::http_actor::HTTPActor, - api::types::{CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMInfo, VmId}, + api::types::{ + CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMInfo, VMListResponse, VmId, + }, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -12,15 +14,26 @@ use axum::{ extract::{Path, State}, }; use kameo::actor::ActorRef; +use odorobo_shared::messages::create_vm::{AgentListVMs, DeleteVM, ShutdownVM}; pub fn router() -> ApiRouter> { ApiRouter::new() + .api_route("/", get(list_vms)) .api_route("/", post(create_vm)) // undocumented debug route, do not use in prod .route("/", axum::routing::put(debug_create_vm)) .api_route("/{vmid}", get(vm_info)) .api_route("/{vmid}", patch(update_vm)) .api_route("/{vmid}", delete(delete_vm)) + .api_route("/{vmid}/shutdown", put(shutdown_vm)) +} + +async fn list_vms(State(state): State>) -> impl IntoApiResponse { + let reply = state.ask(AgentListVMs).await.unwrap(); + + Json(VMListResponse { + vms: reply.vms.into_iter().map(VmId).collect(), + }) } /// Get detailed information about a specific VM @@ -66,7 +79,18 @@ async fn delete_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> impl IntoApiResponse { - // stub + let _reply = state.ask(DeleteVM { vm_id: vmid }).await.unwrap(); + + Json(()) +} + +async fn shutdown_vm( + State(state): State>, + Path(VmId(vmid)): Path, +) -> impl IntoApiResponse { + let _reply = state.ask(ShutdownVM { vm_id: vmid }).await.unwrap(); + + Json(()) } /// Update an existing VM's configuration (e.g. resize, change resources, etc.) diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 8035d9c..8d5204a 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -36,6 +36,21 @@ pub enum Command { #[arg(long)] boot: bool, }, + + /// List VMs currently known by the manager/agent. + List, + + /// Delete a VM by ID. + Delete { + /// VM ID in ULID format + vmid: String, + }, + + /// Shut down a VM by ID. + Shutdown { + /// VM ID in ULID format + vmid: String, + }, } #[derive(Debug, Serialize)] @@ -44,6 +59,15 @@ struct DebugCreateVMRequest { boot: bool, } +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct VmId(String); + +#[derive(Debug, Deserialize)] +struct VMListResponse { + vms: Vec, +} + // the fields are used using debug printing, so we allow dead code warnings #[allow(dead_code)] #[derive(Debug, Deserialize)] @@ -92,6 +116,31 @@ pub async fn run_command(cli: Cli) -> Result<()> { print_message_response(response, "VM create request sent successfully").await?; } + Command::List => { + let url = format!("{}/vms", base_url); + let response = client.get(&url).send().await?; + + if response.status().is_success() { + let body = response.json::().await?; + for vm in body.vms { + println!("{}", vm.0); + } + } else { + print_api_error(response).await?; + } + } + Command::Delete { vmid } => { + let url = format!("{}/vms/{}", base_url, vmid); + let response = client.delete(&url).send().await?; + + print_message_response(response, "VM delete request sent successfully").await?; + } + Command::Shutdown { vmid } => { + let url = format!("{}/vms/{}/shutdown", base_url, vmid); + let response = client.put(&url).send().await?; + + print_message_response(response, "VM shutdown request sent successfully").await?; + } } Ok(()) From fbc2982aa0603d1750069db222a79da497d92b49 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 04:33:46 +0700 Subject: [PATCH 072/189] rename some fields, implement messaging --- odorobo-agent/src/actor/mod.rs | 83 +++++++-- odorobo-agent/src/api/mod.rs | 76 ++++---- odorobo-agent/src/api/vm.rs | 168 +++++++++--------- odorobo-agent/src/lib.rs | 2 +- odorobo-agent/src/main.rs | 24 +-- .../src/state/provisioning/actor/mod.rs | 46 +++++ odorobo-manager/src/actors/http_actor.rs | 2 +- odorobo-manager/src/api/vms.rs | 6 +- odorobo-manager/src/lib.rs | 2 +- odorobo-shared/src/error.rs | 5 +- odorobo-shared/src/messages/create_vm.rs | 24 ++- odoroboctl/src/cli.rs | 2 + 12 files changed, 277 insertions(+), 163 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index d1b917c..c251332 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -3,6 +3,7 @@ use ahash::AHashMap; use bytesize::ByteSize; use kameo::error::ActorStopReason; use kameo::prelude::*; +use kameo::reply::ForwardedReply; use odorobo_shared::messages::create_vm::*; use odorobo_shared::messages::debug::PanicAgent; use odorobo_shared::messages::{Ping, Pong}; @@ -71,6 +72,15 @@ pub struct Config { pub annotations: AHashMap, } +impl AgentActor { + async fn lookup_vm_actor(vmid: Ulid) -> Option> { + ActorRef::::lookup(format!("vm:{}", vmid)) + .await + .ok() + .flatten() + } +} + impl Actor for AgentActor { type Args = (); type Error = Report; @@ -105,6 +115,19 @@ impl Actor for AgentActor { Ok(ControlFlow::Continue(())) } + + async fn on_link_died( + &mut self, + _actor_ref: WeakActorRef, + id: ActorId, + reason: ActorStopReason, + ) -> Result> { + warn!("Linked actor {id:?} died with reason {reason:?}"); + + self.vms.retain(|_, actor_ref| actor_ref.id() != id); + + Ok(ControlFlow::Continue(())) + } } #[remote_message] @@ -112,10 +135,11 @@ impl Message for AgentActor { type Reply = CreateVMReply; async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { - let vmid = msg.vm_id; + let vmid = msg.vmid; let actor_ref = VMActor::spawn((vmid, msg.config.clone())); - let _ = actor_ref.register(vmid.to_string()).await; + let _ = actor_ref.register(format!("vm:{}", vmid)).await; + let _ = actor_ref.register("vm").await; self.vms.insert(vmid, actor_ref.clone()); trace!(?vmid, "spawned VM actor, linking to context"); @@ -137,17 +161,17 @@ impl Message for AgentActor { msg: DeleteVM, _ctx: &mut Context, ) -> Self::Reply { - match self.vms.remove(&msg.vm_id) { + match self.vms.remove(&msg.vmid) { Some(actor_ref) => { - let res = actor_ref.stop_gracefully().await; + let res = actor_ref.tell(msg.clone()).await; if let Err(err) = res { // probably a bad way to do this - warn!(vm_id = %msg.vm_id, ?err, "failed to stop VM actor gracefully, killing"); + warn!(vm_id = %msg.vmid, ?err, "failed to stop VM actor gracefully, killing"); actor_ref.kill(); } } None => { - warn!(vm_id = %msg.vm_id, "VM actor not found for delete"); + warn!(vm_id = %msg.vmid, "VM actor not found for delete"); } } @@ -157,28 +181,49 @@ impl Message for AgentActor { #[remote_message] impl Message for AgentActor { - type Reply = ShutdownVMReply; + type Reply = Result; async fn handle( &mut self, msg: ShutdownVM, _ctx: &mut Context, ) -> Self::Reply { - match self.vms.get(&msg.vm_id) { + match Self::lookup_vm_actor(msg.vmid).await { Some(actor_ref) => { - let res = actor_ref.stop_gracefully().await; + trace!(?msg, "Telling VM to shut down"); + let res = actor_ref.tell(msg.clone()).await; if let Err(err) = res { - // probably a bad way to do this - warn!(vm_id = %msg.vm_id, ?err, "failed to stop VM actor gracefully, killing"); - actor_ref.kill(); + warn!(vm_id = %msg.vmid, ?err, "failed to shutdown VM actor"); } } None => { - warn!(vm_id = %msg.vm_id, "VM actor not found for shutdown"); + warn!(vm_id = %msg.vmid, "VM actor not found for shutdown"); + return Err("VM actor not found for shutdown".to_string()); } } - ShutdownVMReply + Ok(ShutdownVMReply) + } +} +// forward GetVMInfo to VM actor +#[remote_message] +impl Message for AgentActor { + type Reply = ForwardedReply; + + async fn handle( + &mut self, + msg: GetVMInfo, + ctx: &mut Context, + ) -> Self::Reply { + let vmid = msg.vmid; + + match Self::lookup_vm_actor(vmid).await { + Some(actor_ref) => ctx.forward(&actor_ref, msg).await, + None => { + warn!(vm_id = %vmid, "VM actor not found for info lookup"); + ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) + } + } } } @@ -191,7 +236,17 @@ impl Message for AgentActor { _msg: AgentListVMs, _ctx: &mut Context, ) -> Self::Reply { + // look up with cache let vms = self.vms.keys().copied().collect(); + // let vms_actors: Vec<_> = RemoteActorRef::::lookup_all("vm").collect().await; + + // let mut vms = Vec::new(); + // for actor in vms_actors.into_iter().flatten() { + // trace!(?actor, "looking up VM info"); + // if let Ok(reply) = actor.ask(&GetVMInfo).await { + // vms.push(reply.vmid); + // } + // } AgentListVMsReply { vms } } diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index 382adb0..7549663 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -7,46 +7,46 @@ use serde::Serialize; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; mod vm; -pub fn router(port: u16) -> axum::Router<()> { - let info_route = axum::Router::new() - .route("/info", axum::routing::get(info)) - .with_state(port); +// pub fn router(port: u16) -> axum::Router<()> { +// let info_route = axum::Router::new() +// .route("/info", axum::routing::get(info)) +// .with_state(port); - axum::Router::new() - .layer( - TraceLayer::new_for_http() - .on_request(DefaultOnRequest::new()) - .on_response(DefaultOnResponse::new()) - ) - .route("/", axum::routing::get(root)) - .route("/health", axum::routing::get(health)) - .merge(info_route) - .nest("/vms", vm::router()) -} +// axum::Router::new() +// .layer( +// TraceLayer::new_for_http() +// .on_request(DefaultOnRequest::new()) +// .on_response(DefaultOnResponse::new()) +// ) +// .route("/", axum::routing::get(root)) +// .route("/health", axum::routing::get(health)) +// .merge(info_route) +// .nest("/vms", vm::router()) +// } -async fn root() -> &'static str { - env!("CARGO_PKG_VERSION") -} +// async fn root() -> &'static str { +// env!("CARGO_PKG_VERSION") +// } -async fn health() -> &'static str { - "" -} +// async fn health() -> &'static str { +// "" +// } -#[derive(Serialize)] -struct AgentInfo { - version: &'static str, - listening_addresses: Vec, -} +// #[derive(Serialize)] +// struct AgentInfo { +// version: &'static str, +// listening_addresses: Vec, +// } -async fn info(State(port): State) -> Json { - let listening_addresses = if_addrs::get_if_addrs() - .unwrap_or_default() - .into_iter() - .filter(|i| !i.is_loopback()) - .map(|i| format!("{}:{}", i.ip(), port)) - .collect(); - Json(AgentInfo { - version: env!("CARGO_PKG_VERSION"), - listening_addresses, - }) -} +// async fn info(State(port): State) -> Json { +// let listening_addresses = if_addrs::get_if_addrs() +// .unwrap_or_default() +// .into_iter() +// .filter(|i| !i.is_loopback()) +// .map(|i| format!("{}:{}", i.ip(), port)) +// .collect(); +// Json(AgentInfo { +// version: env!("CARGO_PKG_VERSION"), +// listening_addresses, +// }) +// } diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 24b876d..96124cb 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -10,98 +10,98 @@ use tracing::{error, trace}; use super::error::ApiError; use crate::state::VMInstance; -pub fn router() -> axum::Router<()> { - axum::Router::new() - .route("/", axum::routing::get(list_vms)) - // .route("/{vmid}", axum::routing::put(spawn_vm)) - // .route("/{vmid}/config", axum::routing::put(create_vm_config)) - // .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) - // .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) - // .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) - // .route("/{vmid}/boot", axum::routing::put(boot_vm)) - // .route("/{vmid}/pause", axum::routing::put(pause_vm)) - // .route("/{vmid}/resume", axum::routing::put(resume_vm)) - // .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) - // .route( - // "/{vmid}/migrate/receive", - // axum::routing::put(migrate_receive_vm), - // ) - // .route("/{vmid}", axum::routing::get(vm_info)) - // .route("/{vmid}/ping", axum::routing::get(ping_vm)) - // .route("/{vmid}", axum::routing::delete(destroy_vm)) - // .route( - // "/{vmid}/console", - // axum::routing::get(super::console::console_stream), - // ) - // .route( - // "/{vmid}/ch/{*path}", - // axum::routing::any(super::ch::passthrough), - // ) -} +// pub fn router() -> axum::Router<()> { +// axum::Router::new() +// .route("/", axum::routing::get(list_vms)) +// // .route("/{vmid}", axum::routing::put(spawn_vm)) +// // .route("/{vmid}/config", axum::routing::put(create_vm_config)) +// // .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) +// // .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) +// // .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) +// // .route("/{vmid}/boot", axum::routing::put(boot_vm)) +// // .route("/{vmid}/pause", axum::routing::put(pause_vm)) +// // .route("/{vmid}/resume", axum::routing::put(resume_vm)) +// // .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) +// // .route( +// // "/{vmid}/migrate/receive", +// // axum::routing::put(migrate_receive_vm), +// // ) +// // .route("/{vmid}", axum::routing::get(vm_info)) +// // .route("/{vmid}/ping", axum::routing::get(ping_vm)) +// // .route("/{vmid}", axum::routing::delete(destroy_vm)) +// // .route( +// // "/{vmid}/console", +// // axum::routing::get(super::console::console_stream), +// // ) +// // .route( +// // "/{vmid}/ch/{*path}", +// // axum::routing::any(super::ch::passthrough), +// // ) +// } -/// Lists all VMs by their IDs -async fn list_vms() -> Result>, ApiError> { - let vms = VMInstance::list().map_err(|e| ApiError::ListFailed { msg: e.to_string() })?; - Ok(Json(vms.into_iter().map(|i| i.id).collect())) -} +// /// Lists all VMs by their IDs +// async fn list_vms() -> Result>, ApiError> { +// let vms = VMInstance::list().map_err(|e| ApiError::ListFailed { msg: e.to_string() })?; +// Ok(Json(vms.into_iter().map(|i| i.id).collect())) +// } -/// Helper function to get a VM instance by ID, returning an error if not found -fn get_vm(vmid: &str) -> Result { - use crate::state::VMInstance; - VMInstance::validate_vmid(vmid).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; - VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound { - vmid: vmid.to_string(), - }) -} +// /// Helper function to get a VM instance by ID, returning an error if not found +// fn get_vm(vmid: &str) -> Result { +// use crate::state::VMInstance; +// VMInstance::validate_vmid(vmid).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; +// VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound { +// vmid: vmid.to_string(), +// }) +// } -/// Gets detailed information about a specific VM -async fn vm_info( - vmid: Path, -) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; +// /// Gets detailed information about a specific VM +// async fn vm_info( +// vmid: Path, +// ) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; - let info = vm.info().await.map_err(ApiError::vm_info)?; - Ok(Json(info)) -} +// let info = vm.info().await.map_err(ApiError::vm_info)?; +// Ok(Json(info)) +// } -/// Pings the VMM to check if it's running -async fn ping_vm(vmid: Path) -> Result, ApiError> { - let vm = get_vm(&vmid.0)?; - let res = vm.ping().await.map_err(ApiError::vm_info)?; - Ok(Json(res)) -} +// /// Pings the VMM to check if it's running +// async fn ping_vm(vmid: Path) -> Result, ApiError> { +// let vm = get_vm(&vmid.0)?; +// let res = vm.ping().await.map_err(ApiError::vm_info)?; +// Ok(Json(res)) +// } -#[derive(Debug, Deserialize)] -pub struct CreateVmQuery { - #[serde(default)] - /// Whether to boot the VM immediately - /// after creation - /// - /// Defaults to `false`. - pub boot: bool, -} -#[derive(Debug, Deserialize, Serialize)] -pub struct VmSpawnResponse { - pub info: Option, - pub booted: bool, - pub created_config: bool, -} +// #[derive(Debug, Deserialize)] +// pub struct CreateVmQuery { +// #[serde(default)] +// /// Whether to boot the VM immediately +// /// after creation +// /// +// /// Defaults to `false`. +// pub boot: bool, +// } +// #[derive(Debug, Deserialize, Serialize)] +// pub struct VmSpawnResponse { +// pub info: Option, +// pub booted: bool, +// pub created_config: bool, +// } -#[derive(Debug, Deserialize, Serialize)] -pub struct VmMigrateSendResponse { - pub info: Option, -} -#[derive(Debug, Deserialize, Serialize)] -pub struct VmMigrateReceiveResponse { - pub listening_address: String, -} +// #[derive(Debug, Deserialize, Serialize)] +// pub struct VmMigrateSendResponse { +// pub info: Option, +// } +// #[derive(Debug, Deserialize, Serialize)] +// pub struct VmMigrateReceiveResponse { +// pub listening_address: String, +// } -#[derive(Debug, Deserialize)] -pub struct VmMigrateSendRequest { - pub destination: String, - #[serde(default)] - pub local: bool, -} +// #[derive(Debug, Deserialize)] +// pub struct VmMigrateSendRequest { +// pub destination: String, +// #[serde(default)] +// pub local: bool, +// } // /// Sends a live migration to the given destination URL. // /// Note: the source VMM exits after migration completes, so no VM info is returned. diff --git a/odorobo-agent/src/lib.rs b/odorobo-agent/src/lib.rs index 744de27..5db2813 100644 --- a/odorobo-agent/src/lib.rs +++ b/odorobo-agent/src/lib.rs @@ -1,4 +1,4 @@ pub mod actor; mod api; -mod state; +pub mod state; mod util; diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 9632165..1835175 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -19,18 +19,18 @@ async fn main() -> Result<()> { // minimal axum server, debug socket // // todo: remove this, here to stub out dead code - tokio::task::spawn(async { - let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; - let port = listener.local_addr()?.port(); - let addrs: Vec = if_addrs::get_if_addrs()? - .into_iter() - .filter(|i| !i.is_loopback()) - .map(|i| format!("http://{}:{}", i.ip(), port)) - .collect(); - tracing::info!(port, ?addrs, "Listening"); - axum::serve(listener, api::router(port)).await?; - Ok::<(), stable_eyre::Report>(()) - }); + // tokio::task::spawn(async { + // let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; + // let port = listener.local_addr()?.port(); + // let addrs: Vec = if_addrs::get_if_addrs()? + // .into_iter() + // .filter(|i| !i.is_loopback()) + // .map(|i| format!("http://{}:{}", i.ip(), port)) + // .collect(); + // tracing::info!(port, ?addrs, "Listening"); + // axum::serve(listener, api::router(port)).await?; + // Ok::<(), stable_eyre::Report>(()) + // }); let local_peer_id = connect_to_swarm().unwrap(); tracing::info!(?local_peer_id, "Peer ID"); diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 8892aac..0a625d7 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -3,11 +3,16 @@ use crate::state::VMInstance; use crate::state::provisioning::default_provisioner; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; +use odorobo_shared::messages::create_vm::DeleteVM; +use odorobo_shared::messages::create_vm::GetVMInfo; +use odorobo_shared::messages::create_vm::GetVMInfoReply; +use odorobo_shared::messages::create_vm::ShutdownVM; use stable_eyre::Report; use stable_eyre::Result; use std::path::PathBuf; use tracing::error; use tracing::info; +use tracing::trace; use tracing::warn; /* use std::process::Command; @@ -94,6 +99,47 @@ impl From for VMInstance { } } + +impl Message for VMActor { + type Reply = GetVMInfoReply; + async fn handle( + &mut self, + _msg: GetVMInfo, + _ctx: &mut Context, + ) -> Self::Reply { + GetVMInfoReply { + vmid: self.vmid, + config: self.vm_instance.vm_config.clone(), + } + } +} + +impl Message for VMActor { + type Reply = (); + async fn handle( + &mut self, + _msg: ShutdownVM, + ctx: &mut Context, + ) -> Self::Reply { + trace!(vmid = %self.vmid, "Shutting down VM actor"); + ctx.actor_ref().stop_gracefully().await.unwrap(); + // ctx.actor_ref().kill(); + } +} + + +impl Message for VMActor { + type Reply = (); + async fn handle( + &mut self, + _msg: DeleteVM, + ctx: &mut Context, + ) -> Self::Reply { + trace!(vmid = %self.vmid, "Shutting down VM actor"); + ctx.actor_ref().stop_gracefully().await.unwrap(); + } +} + // /// Provisioner backend for VM instances using an actor-based model // pub struct ActorProvisioner; diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index 4cc0a1e..5fd982e 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -21,7 +21,7 @@ pub struct HTTPActor { impl HTTPActor { pub fn create_vm_message(request: CreateVMRequest) -> CreateVM { CreateVM { - vm_id: request.data.id, + vmid: request.data.id, config: VmConfig { cpus: Some(CpusConfig { boot_vcpus: request.data.vcpus as i32, diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index 3d5e1da..653eee3 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -66,7 +66,7 @@ async fn debug_create_vm( Json(request): Json, ) -> impl IntoApiResponse { let message = odorobo_shared::messages::create_vm::CreateVM { - vm_id: ulid::Ulid::new(), + vmid: ulid::Ulid::new(), config: request.vm_config, }; @@ -79,7 +79,7 @@ async fn delete_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> impl IntoApiResponse { - let _reply = state.ask(DeleteVM { vm_id: vmid }).await.unwrap(); + let _reply = state.ask(DeleteVM { vmid }).await.unwrap(); Json(()) } @@ -88,7 +88,7 @@ async fn shutdown_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> impl IntoApiResponse { - let _reply = state.ask(ShutdownVM { vm_id: vmid }).await.unwrap(); + let _reply = state.ask(ShutdownVM { vmid }).await.unwrap(); Json(()) } diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs index 74f9bdd..dfc5c83 100644 --- a/odorobo-manager/src/lib.rs +++ b/odorobo-manager/src/lib.rs @@ -1,2 +1,2 @@ -pub mod actors; pub mod api; +pub mod actors; \ No newline at end of file diff --git a/odorobo-shared/src/error.rs b/odorobo-shared/src/error.rs index 1d238b4..40c75fe 100644 --- a/odorobo-shared/src/error.rs +++ b/odorobo-shared/src/error.rs @@ -1,7 +1,8 @@ +use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] pub enum OdoroboError { - #[error("")] - Foobar, + #[error("{0}")] + Report(#[from] stable_eyre::Report), } diff --git a/odorobo-shared/src/messages/create_vm.rs b/odorobo-shared/src/messages/create_vm.rs index cf173f9..33ef0b7 100644 --- a/odorobo-shared/src/messages/create_vm.rs +++ b/odorobo-shared/src/messages/create_vm.rs @@ -13,7 +13,7 @@ use ulid::Ulid; #[derive(Serialize, Deserialize, Debug)] pub struct CreateVM { /// the ULID of the VM to create - pub vm_id: Ulid, + pub vmid: Ulid, /// VmConfig in message, untransformed. /// /// Transformer API will transform this VmConfig into proper @@ -28,18 +28,17 @@ pub struct CreateVMReply { pub config: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeleteVM { - pub vm_id: Ulid, + pub vmid: Ulid, } -#[derive(Serialize, Deserialize, Reply, Debug)] +#[derive(Serialize, Deserialize, Reply, Debug, Clone)] pub struct DeleteVMReply; - -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ShutdownVM { - pub vm_id: Ulid, + pub vmid: Ulid, } #[derive(Serialize, Deserialize, Reply, Debug)] @@ -53,3 +52,14 @@ pub struct AgentListVMsReply { // list VMs pub vms: Vec, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetVMInfo { + pub vmid: Ulid, +} + +#[derive(Serialize, Deserialize, Reply, Debug)] +pub struct GetVMInfoReply { + pub vmid: Ulid, + pub config: Option, +} diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 8d5204a..98bdbf0 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -94,6 +94,8 @@ async fn print_api_error(response: Response) -> Result<()> { async fn print_message_response(response: Response, success_message: &str) -> Result<()> { if response.status().is_success() { + let text = response.text().await?; + println!("{text}"); println!("{success_message}"); } else { print_api_error(response).await?; From b171783a07566369afedcddcb7c61a4d8a3e7071 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 04:40:18 +0700 Subject: [PATCH 073/189] merge imports --- odorobo-agent/src/actor/mod.rs | 6 +----- .../src/state/provisioning/actor/mod.rs | 18 +++--------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index c251332..38313e2 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -1,12 +1,8 @@ use crate::state::provisioning::actor::VMActor; use ahash::AHashMap; use bytesize::ByteSize; -use kameo::error::ActorStopReason; use kameo::prelude::*; -use kameo::reply::ForwardedReply; -use odorobo_shared::messages::create_vm::*; -use odorobo_shared::messages::debug::PanicAgent; -use odorobo_shared::messages::{Ping, Pong}; +use odorobo_shared::messages::{Ping, Pong, create_vm::*, debug::PanicAgent}; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::fs; diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 0a625d7..279720e 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -1,19 +1,9 @@ -use super::VMProvisionerBackend; use crate::state::VMInstance; -use crate::state::provisioning::default_provisioner; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; -use odorobo_shared::messages::create_vm::DeleteVM; -use odorobo_shared::messages::create_vm::GetVMInfo; -use odorobo_shared::messages::create_vm::GetVMInfoReply; -use odorobo_shared::messages::create_vm::ShutdownVM; -use stable_eyre::Report; -use stable_eyre::Result; -use std::path::PathBuf; -use tracing::error; -use tracing::info; -use tracing::trace; -use tracing::warn; +use odorobo_shared::messages::create_vm::{DeleteVM, GetVMInfo, GetVMInfoReply, ShutdownVM}; +use stable_eyre::{Report, Result}; +use tracing::{error, info, trace, warn}; /* use std::process::Command; @@ -99,7 +89,6 @@ impl From for VMInstance { } } - impl Message for VMActor { type Reply = GetVMInfoReply; async fn handle( @@ -127,7 +116,6 @@ impl Message for VMActor { } } - impl Message for VMActor { type Reply = (); async fn handle( From e98c4079e7bc092e032e69d3e122b400b7e51c78 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 04:44:31 +0700 Subject: [PATCH 074/189] remove provisioner traits We don't actually want indirection within Cloud Hypervisor anymore, as now we actually want them to be inside the actors themselves --- odorobo-agent/src/state/provisioning/mod.rs | 82 ---------- .../src/state/provisioning/systemd/mod.rs | 20 --- .../src/state/provisioning/systemd/proxy.rs | 147 ------------------ 3 files changed, 249 deletions(-) delete mode 100644 odorobo-agent/src/state/provisioning/systemd/mod.rs delete mode 100644 odorobo-agent/src/state/provisioning/systemd/proxy.rs diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo-agent/src/state/provisioning/mod.rs index 5e8411d..be490cc 100644 --- a/odorobo-agent/src/state/provisioning/mod.rs +++ b/odorobo-agent/src/state/provisioning/mod.rs @@ -4,85 +4,3 @@ //! Cloud Hypervisor process for a given instance pub mod actor; pub mod hooks; -mod systemd; -use cloud_hypervisor_client::models::VmConfig; -use stable_eyre::{Result, eyre::Context}; -use tracing::info; - -use self::hooks::HookManager; -use crate::state::provisioning::systemd::SystemdUnitProvisioner; - -pub struct VMProvisioner { - backend: B, - hooks: HookManager, -} - -impl VMProvisioner { - pub fn with_hooks(backend: B, hooks: HookManager) -> Self { - Self { backend, hooks } - } - - #[tracing::instrument(skip(self))] - pub async fn start_instance(&self, vmid: &str, config: &VmConfig) -> Result { - info!(?vmid, "Starting instance"); - self.hooks - .before_start(vmid, config) - .await - .wrap_err_with(|| format!("before_start hook failed for VM {vmid}"))?; - - let pid = self - .backend - .start_instance(vmid) - .await - .wrap_err_with(|| format!("Failed to start VM instance {vmid}"))?; - - self.hooks - .after_start(vmid, config, pid) - .await - .wrap_err_with(|| format!("after_start hook failed for VM {vmid}"))?; - - Ok(pid) - } - - #[tracing::instrument(skip(self))] - pub async fn stop_instance(&self, vmid: &str, config: &VmConfig) -> Result<()> { - info!(?vmid, "Stopping instance"); - self.hooks - .before_stop(vmid, config) - .await - .wrap_err_with(|| format!("before_stop hook failed for VM {vmid}"))?; - - self.backend - .stop_instance(vmid) - .await - .wrap_err_with(|| format!("Failed to stop VM instance {vmid}"))?; - - self.hooks - .after_stop(vmid, config) - .await - .wrap_err_with(|| format!("after_stop hook failed for VM {vmid}")) - } - - pub async fn before_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { - self.hooks - .before_boot(vmid, config) - .await - .wrap_err_with(|| format!("before_start hook failed for VM {vmid}")) - } - - pub async fn after_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { - self.hooks - .after_boot(vmid, config) - .await - .wrap_err_with(|| format!("after_start hook failed for VM {vmid}")) - } -} - -pub trait VMProvisionerBackend: Send + Sync { - async fn start_instance(&self, vmid: &str) -> Result; - async fn stop_instance(&self, vmid: &str) -> Result<()>; -} - -pub fn default_provisioner() -> VMProvisioner { - VMProvisioner::with_hooks(SystemdUnitProvisioner, HookManager::default()) -} diff --git a/odorobo-agent/src/state/provisioning/systemd/mod.rs b/odorobo-agent/src/state/provisioning/systemd/mod.rs deleted file mode 100644 index 48d20c1..0000000 --- a/odorobo-agent/src/state/provisioning/systemd/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod proxy; -use super::VMProvisionerBackend; -use proxy::{start_instance, stop_instance}; -use stable_eyre::{Result, eyre::WrapErr}; - -pub struct SystemdUnitProvisioner; - -impl VMProvisionerBackend for SystemdUnitProvisioner { - async fn start_instance(&self, vmid: &str) -> Result { - start_instance(vmid) - .await - .wrap_err_with(|| format!("Failed to start instance {vmid} with systemd unit")) - } - - async fn stop_instance(&self, vmid: &str) -> Result<()> { - stop_instance(vmid) - .await - .wrap_err_with(|| format!("Failed to stop instance {vmid} with systemd unit")) - } -} diff --git a/odorobo-agent/src/state/provisioning/systemd/proxy.rs b/odorobo-agent/src/state/provisioning/systemd/proxy.rs deleted file mode 100644 index c5e28af..0000000 --- a/odorobo-agent/src/state/provisioning/systemd/proxy.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! systemd provisioning module for odorobo agent - -use crate::util::{systemd_instance_unit_name, zbus_system_connection}; -/// This module provides functions for provisioning CH instances for odorobo agent -/// using systemd. -/// -/// All it should do is simply just start and stop systemd services with the correct parameters -use stable_eyre::{Result, eyre::WrapErr, eyre::eyre}; -use tokio::time::{Duration, sleep}; -use tracing::trace; -use zbus::Connection; -use zbus_systemd::systemd1::{ManagerProxy, ServiceProxy}; -/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name -// pub fn instance_unit_name(vmid: &str) -> String { -// format!("odorobo-ch@{vmid}.service") -// } - -// async fn system_connection() -> Result { -// Connection::system() -// .await -// .wrap_err("Failed to connect to system D-Bus") -// } - -pub async fn manager_proxy(connection: &Connection) -> Result> { - ManagerProxy::new(connection) - .await - .wrap_err("Failed to create systemd manager proxy") -} - -pub async fn service_proxy<'a>( - connection: &'a Connection, - unit_name: &str, -) -> Result> { - let manager = manager_proxy(connection).await?; - let unit_path = manager - .load_unit(unit_name.to_string()) - .await - .wrap_err_with(|| format!("Failed to load systemd unit {unit_name}"))?; - - ServiceProxy::builder(connection) - .path(unit_path) - .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? - .build() - .await - .wrap_err_with(|| format!("Failed to create service proxy for systemd unit {unit_name}")) -} - -#[tracing::instrument] -pub async fn start_instance(vmid: &str) -> Result { - let connection = zbus_system_connection().await?; - let manager = manager_proxy(&connection).await?; - let unit_name = systemd_instance_unit_name(vmid); - trace!(?unit_name, "Starting systemd unit"); - - manager - .start_unit(unit_name.clone(), "replace".into()) - .await - .wrap_err_with(|| format!("Failed to start systemd unit {unit_name}"))?; - - let unit_path = manager - .load_unit(unit_name.clone()) - .await - .wrap_err_with(|| format!("Failed to load systemd unit {unit_name}"))?; - - let unit = zbus_systemd::systemd1::UnitProxy::builder(&connection) - .path(unit_path.clone()) - .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? - .build() - .await - .wrap_err_with(|| format!("Failed to create unit proxy for systemd unit {unit_name}"))?; - - let service = zbus_systemd::systemd1::ServiceProxy::builder(&connection) - .path(&unit_path) - .wrap_err_with(|| format!("Failed to build path for systemd unit {unit_name}"))? - .build() - .await - .wrap_err_with(|| format!("Failed to create service proxy for systemd unit {unit_name}"))?; - - // start_unit enqueues a job but doesn't wait for it to complete. - // Poll until the service reaches a terminal state so we can return a real PID. - const MAX_WAIT: u32 = 30; - for attempt in 0..MAX_WAIT { - let state = unit - .active_state() - .await - .wrap_err_with(|| format!("Failed to get ActiveState for {unit_name}"))?; - - trace!( - ?unit_name, - ?state, - attempt, - "Waiting for systemd unit to start" - ); - - match state.as_str() { - "active" => { - let pid = service - .main_pid() - .await - .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; - return Ok(pid as i32); - } - "failed" => { - return Err(eyre!("Systemd unit {unit_name} failed to start")); - } - _ => {} - } - - if attempt < MAX_WAIT - 1 { - sleep(Duration::from_millis(500)).await; - } - } - - Err(eyre!( - "Systemd unit {unit_name} did not become active within {MAX_WAIT} attempts" - )) -} - -#[tracing::instrument] -pub async fn stop_instance(vmid: &str) -> Result<()> { - trace!(?vmid, "Stopping instance"); - let connection = zbus_system_connection().await?; - let manager = manager_proxy(&connection).await?; - let unit_name = systemd_instance_unit_name(vmid); - - manager - .stop_unit(unit_name.clone(), "replace".into()) - .await - .wrap_err_with(|| format!("Failed to stop systemd unit {unit_name}"))?; - - Ok(()) -} - -#[tracing::instrument] -pub async fn get_main_pid(vmid: &str) -> Result { - trace!(?vmid, "Getting MainPID for instance"); - let connection = zbus_system_connection().await?; - let unit_name = systemd_instance_unit_name(vmid); - let service = service_proxy(&connection, &unit_name).await?; - - let pid = service - .main_pid() - .await - .wrap_err_with(|| format!("Failed to get MainPID for {unit_name}"))?; - - Ok(pid as i32) -} From 3970f5ae46edd8f99cf1bf0feffaf41248e3bbe6 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 05:04:55 +0700 Subject: [PATCH 075/189] do keepalive pings --- odorobo-manager/src/actors/scheduler_actor.rs | 27 +++++++++++++++++++ odorobo-shared/src/lib.rs | 5 ++-- odorobo-shared/src/utils.rs | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 640bcf2..72048e2 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -1,4 +1,5 @@ use std::ops::ControlFlow; +use std::time::Duration; use kameo::prelude::*; use odorobo_agent::actor::AgentActor; @@ -29,6 +30,7 @@ impl SchedulerActor { info!("Using agent actor peer id: {agent_actor_peer_id}"); // remotely link actor, on link death it will be automatically unlinked + info!("Linking agent actor: {agent_actor_peer_id}"); actor_ref.link_remote(&agent_actor).await?; return Ok(agent_actor); @@ -60,6 +62,18 @@ impl Actor for SchedulerActor { let agent_actor = Self::lookup_agent(&actor_ref).await?; + let ping_actor = agent_actor.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + loop { + interval.tick().await; + if let Err(err) = ping_actor.ask(&Ping).await { + warn!("Periodic ping to agent failed: {err}"); + break; + } + } + }); + Ok(Self { agent_actor: Some(agent_actor), }) @@ -80,6 +94,19 @@ impl Actor for SchedulerActor { }; let new_agent = Self::lookup_agent(&actor_ref).await?; + + let ping_actor = new_agent.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + loop { + interval.tick().await; + if let Err(err) = ping_actor.ask(&Ping).await { + warn!("Periodic ping to agent failed: {err}"); + break; + } + } + }); + self.agent_actor = Some(new_agent); Ok(ControlFlow::Continue(())) diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index 861e234..4bf7051 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -9,7 +9,8 @@ use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::{PeerId, kad, mdns, noise, tcp, yamux}; use stable_eyre::Result; use std::cell::RefCell; -use tracing::{debug, info, warn}; +use std::time::Duration; +use tracing::{debug, info, trace, warn}; #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { @@ -77,7 +78,7 @@ pub fn connect_to_swarm() -> Result { SwarmEvent::Behaviour(ProductionBehaviourEvent::Kameo( remote::Event::Messaging(messaging_event), )) => { - debug!(?messaging_event, "Messaging event"); + trace!(?messaging_event, "Messaging event"); } // Handle other swarm events SwarmEvent::NewListenAddr { address, .. } => { diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index 807a134..9a15572 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -18,7 +18,7 @@ pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { }; base.add_directive( - format!("{}=trace", env!("CARGO_PKG_NAME").replace('-', "_")) + format!("{}=debug", env!("CARGO_PKG_NAME").replace('-', "_")) .parse() .unwrap(), ) From ba7cd933b498fc53536b99ece0ea1bbdede734f3 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 06:52:54 +0700 Subject: [PATCH 076/189] fix IPAM MAC address calculation --- odorobo-manager/src/actors/ip_management_actor.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/odorobo-manager/src/actors/ip_management_actor.rs b/odorobo-manager/src/actors/ip_management_actor.rs index 5c590dc..4ac4627 100644 --- a/odorobo-manager/src/actors/ip_management_actor.rs +++ b/odorobo-manager/src/actors/ip_management_actor.rs @@ -11,11 +11,18 @@ pub const FYRA_OUI: [u8; 3] = [0x46, 0x59, 0x52]; /// Takes the last 3 bytes of the IP address and combines them with the FYRA OUI prefix. pub fn calculate_mac_address(ip: [u8; 4]) -> [u8; 6] { let mut mac = [0u8; 6]; - mac[0..2].copy_from_slice(&FYRA_OUI); - mac[2..].copy_from_slice(&ip[2..]); + mac[0..3].copy_from_slice(&FYRA_OUI); + mac[3..].copy_from_slice(&ip[1..]); mac } +#[test] +fn test_calculate_mac_address() { + let ip = [192, 168, 1, 1]; + let mac = calculate_mac_address(ip); + assert_eq!(mac, [0x46, 0x59, 0x52, 168, 0x01, 0x01]); +} + /// HTTP REST API service #[derive(RemoteActor)] pub struct IPManagementActor; From a87521f85c85d0e144026da17e7c4162d690bd65 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 07:15:35 +0700 Subject: [PATCH 077/189] scheduler: call VM actor directly isntead of going through agent for VM operations --- odorobo-agent/src/actor/mod.rs | 10 +-- .../src/state/provisioning/actor/mod.rs | 5 +- odorobo-manager/src/actors/http_actor.rs | 10 ++- odorobo-manager/src/actors/scheduler_actor.rs | 75 +++++++++---------- odorobo-manager/src/api/vms.rs | 15 +++- odorobo-shared/src/utils.rs | 5 ++ 6 files changed, 68 insertions(+), 52 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 38313e2..ad3ba79 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -2,7 +2,7 @@ use crate::state::provisioning::actor::VMActor; use ahash::AHashMap; use bytesize::ByteSize; use kameo::prelude::*; -use odorobo_shared::messages::{Ping, Pong, create_vm::*, debug::PanicAgent}; +use odorobo_shared::{messages::{Ping, Pong, create_vm::*, debug::PanicAgent}, utils::vm_actor_id}; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::fs; @@ -132,15 +132,13 @@ impl Message for AgentActor { async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { let vmid = msg.vmid; - let actor_ref = VMActor::spawn((vmid, msg.config.clone())); + // spawn AND link at the same time + let actor_ref = VMActor::spawn_link(ctx.actor_ref(), (vmid, msg.config.clone())).await; - let _ = actor_ref.register(format!("vm:{}", vmid)).await; + let _ = actor_ref.register(vm_actor_id(vmid)).await; let _ = actor_ref.register("vm").await; self.vms.insert(vmid, actor_ref.clone()); - trace!(?vmid, "spawned VM actor, linking to context"); - ctx.actor_ref().link(&actor_ref).await; - info!(?vmid, "VM Spawned successfully"); CreateVMReply { config: Some(msg.config), diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 279720e..7756162 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -88,7 +88,7 @@ impl From for VMInstance { actor.vm_instance } } - +#[remote_message] impl Message for VMActor { type Reply = GetVMInfoReply; async fn handle( @@ -103,6 +103,7 @@ impl Message for VMActor { } } +#[remote_message] impl Message for VMActor { type Reply = (); async fn handle( @@ -115,7 +116,7 @@ impl Message for VMActor { // ctx.actor_ref().kill(); } } - +#[remote_message] impl Message for VMActor { type Reply = (); async fn handle( diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index 5fd982e..80c963e 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -1,8 +1,12 @@ use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; -use odorobo_shared::messages::create_vm::{ - AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, - ShutdownVMReply, +use odorobo_agent::state::provisioning::actor::VMActor; +use odorobo_shared::{ + messages::create_vm::{ + AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, + ShutdownVM, ShutdownVMReply, + }, + utils::vm_actor_id, }; use stable_eyre::{Report, Result}; diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 72048e2..4cc056e 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -3,8 +3,10 @@ use std::time::Duration; use kameo::prelude::*; use odorobo_agent::actor::AgentActor; +use odorobo_agent::state::provisioning::actor::VMActor; use odorobo_shared::messages::create_vm::*; use odorobo_shared::messages::{Ping, Pong}; +use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, warn}; @@ -142,25 +144,18 @@ impl Message for SchedulerActor { impl Message for SchedulerActor { type Reply = Result; - async fn handle(&mut self, msg: DeleteVM, ctx: &mut Context) -> Self::Reply { - let actor_ref = ctx.actor_ref(); - - let first_agent = self.ensure_agent(&actor_ref).await?; - match first_agent.ask(&msg).await { - Ok(reply) => Ok(reply), - Err(first_err) => { - warn!( - "DeleteVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" - ); - self.agent_actor = None; - - let retry_agent = self.ensure_agent(&actor_ref).await?; - retry_agent.ask(&msg).await.map_err(|retry_err| { - eyre!( - "failed to forward DeleteVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" - ) - }) - } + async fn handle( + &mut self, + msg: DeleteVM, + _ctx: &mut Context, + ) -> Self::Reply { + let vm = RemoteActorRef::::lookup(vm_actor_id(msg.vmid)).await?; + tracing::trace!("DeleteVM: vm={:?}", vm); + if let Some(vm) = vm { + vm.tell(&msg).send()?; + Ok(DeleteVMReply) + } else { + Err(eyre!("VM not found")) } } } @@ -171,26 +166,15 @@ impl Message for SchedulerActor { async fn handle( &mut self, msg: ShutdownVM, - ctx: &mut Context, + _ctx: &mut Context, ) -> Self::Reply { - let actor_ref = ctx.actor_ref(); - - let first_agent = self.ensure_agent(&actor_ref).await?; - match first_agent.ask(&msg).await { - Ok(reply) => Ok(reply), - Err(first_err) => { - warn!( - "ShutdownVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" - ); - self.agent_actor = None; - - let retry_agent = self.ensure_agent(&actor_ref).await?; - retry_agent.ask(&msg).await.map_err(|retry_err| { - eyre!( - "failed to forward ShutdownVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" - ) - }) - } + let vm = RemoteActorRef::::lookup(vm_actor_id(msg.vmid)).await?; + tracing::trace!("ShutdownVM: vm={:?}", vm); + if let Some(vm) = vm { + vm.tell(&msg).send()?; + Ok(ShutdownVMReply) + } else { + Err(eyre!("VM not found")) } } } @@ -203,6 +187,21 @@ impl Message for SchedulerActor { msg: AgentListVMs, ctx: &mut Context, ) -> Self::Reply { + // Direct VM discovery attempt kept for reference, but it does not work reliably. + // let mut vm_actors = RemoteActorRef::::lookup_all("vm"); + // let mut vms = Vec::new(); + // + // while let Some(vm_actor) = vm_actors.try_next().await? { + // tracing::trace!("AgentListVMs: vm_actor={:?}", vm_actor); + // + // match vm_actor.ask(&GetVMInfo { vmid: ulid::Ulid::nil() }).await { + // Ok(reply) => vms.push(reply.vmid), + // Err(err) => warn!("failed to query VM actor info while listing VMs: {err}"), + // } + // } + // + // Ok(AgentListVMsReply { vms }) + let actor_ref = ctx.actor_ref(); let first_agent = self.ensure_agent(&actor_ref).await?; diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index 653eee3..f97f89e 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -2,7 +2,8 @@ use crate::{ actors::http_actor::HTTPActor, api::types::{ - CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMInfo, VMListResponse, VmId, + CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VMInfo, VMListResponse, + VmId, }, }; use aide::axum::{ @@ -65,14 +66,22 @@ async fn debug_create_vm( State(state): State>, Json(request): Json, ) -> impl IntoApiResponse { + let ulid = ulid::Ulid::new(); let message = odorobo_shared::messages::create_vm::CreateVM { - vmid: ulid::Ulid::new(), + vmid: ulid, config: request.vm_config, }; let _reply = state.ask(message).await.unwrap(); - Json(VMInfo::default()) + Json(VMInfo { + status: crate::api::types::VMStatus::Provisioning, + data: VMData { + id: ulid, + ..Default::default() + }, + ..Default::default() + }) } async fn delete_vm( diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index 9a15572..82e0a3a 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -1,6 +1,7 @@ use stable_eyre::Result; // use tracing:: use tracing_subscriber::{EnvFilter, filter::LevelFilter, fmt, prelude::*}; +use ulid::Ulid; pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); @@ -27,6 +28,10 @@ pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { base } +pub fn vm_actor_id(vmid: Ulid) -> String { + format!("vm:{}", vmid) +} + pub fn init(debug_target: Option<&str>) -> Result<()> { stable_eyre::install()?; tracing_subscriber::fmt() From bc960913aa824ad9ed8b146a2c433fc8d2c796a7 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 07:19:23 +0700 Subject: [PATCH 078/189] don't unwrap delete vm request --- odorobo-manager/src/api/vms.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index f97f89e..9175da9 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -88,7 +88,7 @@ async fn delete_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> impl IntoApiResponse { - let _reply = state.ask(DeleteVM { vmid }).await.unwrap(); + let _reply = state.ask(DeleteVM { vmid }).await; Json(()) } From 68e907da523a0fadd0abdde17bd92db020ca6340 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 10 Apr 2026 07:27:40 +0700 Subject: [PATCH 079/189] logging: add pretty log only on debug builds --- odorobo-shared/src/utils.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index 82e0a3a..ee710e7 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -34,11 +34,16 @@ pub fn vm_actor_id(vmid: Ulid) -> String { pub fn init(debug_target: Option<&str>) -> Result<()> { stable_eyre::install()?; - tracing_subscriber::fmt() - .with_env_filter(env_filter(debug_target)) - .with_file(true) - .with_line_number(true) - .init(); + let fmt = tracing_subscriber::fmt().with_env_filter(env_filter(debug_target)); + #[cfg(debug_assertions)] + let fmt = { + fmt.pretty() + .with_file(true) + .with_line_number(true) + .with_ansi(true) + }; + + fmt.init(); Ok(()) } From 607f53796ab2f11300d654724d029d3da6e8f5a0 Mon Sep 17 00:00:00 2001 From: Hero Date: Fri, 10 Apr 2026 06:07:47 -0500 Subject: [PATCH 080/189] actor info ideas --- actor_info.txt | 20 ++++++++++++++++++++ odorobo-manager/src/actors/mod.rs | 1 + odorobo-manager/src/actors/storage_actor.rs | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 actor_info.txt diff --git a/actor_info.txt b/actor_info.txt new file mode 100644 index 0000000..f19f826 --- /dev/null +++ b/actor_info.txt @@ -0,0 +1,20 @@ +manager: + +http server (mvp) +websocket server for serial terminal (mvp) + +ip management (future roadmap) (also should be a trait so we can swap this out with netbox or something custom later) + +managed hosting provider (hits schedulers to auto manage containers, ansible, docker compose, or anything else, and do it to a new vm, an existing vm, or create a new dedicated server and use it) + +scheduler_non-ha (mvp) (needs a scheduler trait so schedulers are similar, should be based on inital scheduler_non_ha) +scheduler_ha (future roadmap) +scheduler_other_clusters (future roadmap) +scheduler_to_other_manager (future roadmap) + + + +agent: +server actor (mvp) (needs to be a trait so we can swap for other things in the future) +vm actor (mvp) (needs to be a trait so we can swap for other things in the future + diff --git a/odorobo-manager/src/actors/mod.rs b/odorobo-manager/src/actors/mod.rs index 8a4c193..6ef1cb9 100644 --- a/odorobo-manager/src/actors/mod.rs +++ b/odorobo-manager/src/actors/mod.rs @@ -2,3 +2,4 @@ pub mod http_actor; pub mod ip_management_actor; pub mod scheduler_actor; pub mod serial_terminal_websocket_actor; +pub mod storage_actor; \ No newline at end of file diff --git a/odorobo-manager/src/actors/storage_actor.rs b/odorobo-manager/src/actors/storage_actor.rs index d373b5c..d22abb6 100644 --- a/odorobo-manager/src/actors/storage_actor.rs +++ b/odorobo-manager/src/actors/storage_actor.rs @@ -3,7 +3,7 @@ use tracing::{error, info, warn}; use stable_eyre::{Report, Result}; -/// HTTP REST API service +/// #[derive(RemoteActor)] pub struct SerialTerminalWebsocketActor; From c45043f3b35be743b97e9a1a72809f1121ea172d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 11 Apr 2026 12:44:10 +0700 Subject: [PATCH 081/189] implement basic live migration --- odorobo-agent/src/actor/mod.rs | 32 ++++- odorobo-agent/src/state/instance.rs | 31 +++- .../src/state/provisioning/actor/mod.rs | 136 +++++++++++++++++- odorobo-manager/src/actors/http_actor.rs | 2 +- odorobo-manager/src/actors/scheduler_actor.rs | 2 +- odorobo-manager/src/api/vms.rs | 4 +- odorobo-shared/src/messages/mod.rs | 2 +- .../src/messages/{create_vm.rs => vm.rs} | 39 ++++- 8 files changed, 231 insertions(+), 17 deletions(-) rename odorobo-shared/src/messages/{create_vm.rs => vm.rs} (66%) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index ad3ba79..9330ab7 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -2,7 +2,10 @@ use crate::state::provisioning::actor::VMActor; use ahash::AHashMap; use bytesize::ByteSize; use kameo::prelude::*; -use odorobo_shared::{messages::{Ping, Pong, create_vm::*, debug::PanicAgent}, utils::vm_actor_id}; +use odorobo_shared::{ + messages::{Ping, Pong, debug::PanicAgent, vm::*}, + utils::vm_actor_id, +}; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::fs; @@ -133,7 +136,8 @@ impl Message for AgentActor { async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { let vmid = msg.vmid; // spawn AND link at the same time - let actor_ref = VMActor::spawn_link(ctx.actor_ref(), (vmid, msg.config.clone())).await; + let actor_ref = + VMActor::spawn_link(ctx.actor_ref(), (vmid, Some(msg.config.clone()))).await; let _ = actor_ref.register(vm_actor_id(vmid)).await; let _ = actor_ref.register("vm").await; @@ -146,6 +150,30 @@ impl Message for AgentActor { } } +#[remote_message] +impl Message for AgentActor { + type Reply = MigrateVMReceiveReply; + + async fn handle( + &mut self, + msg: MigrateVMReceive, + ctx: &mut Context, + ) -> Self::Reply { + let vmid = msg.vmid; + let actor_ref = VMActor::spawn_link(ctx.actor_ref(), (vmid, None)).await; + + let _ = actor_ref.register(vm_actor_id(vmid)).await; + let _ = actor_ref.register("vm").await; + self.vms.insert(vmid, actor_ref.clone()); + + // now ask the VM actor to handle the migration receive + actor_ref + .ask(msg) + .await + .expect("failed to start migration receiver on destination VM actor") + } +} + #[remote_message] impl Message for AgentActor { type Reply = DeleteVMReply; diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index e76cab4..8ea869c 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -16,6 +16,7 @@ use std::{ path::{Path, PathBuf}, }; use thiserror::Error; +use tokio::task::JoinHandle; use tracing::{debug, error, info, trace, warn}; use crate::state::{ @@ -146,7 +147,6 @@ impl VMInstance { /// - `dest`: the destination URI to migrate to, in the format expected by CH (e.g. "tcp::12345") /// - `local`: if true, indicates that the migration is local (e.g. within the same host, for renaming a VM). This is passed to CH and may affect how the migration is performed. #[tracing::instrument] - #[tracing::instrument] pub async fn send_migration(&mut self, dest: &str, local: bool) -> Result<()> { let conn = self.conn(); trace!(destination = dest, "Sending migration command to VM"); @@ -179,7 +179,7 @@ impl VMInstance { /// so it's currently up to the caller to make sure that the receiver is ready before the sender tries to connect. /// Future improvement: add some kind of global tracker for active migrations and their states. #[tracing::instrument] - pub async fn receive_migration(&self) -> Result { + pub async fn receive_migration(&self) -> Result<(String, JoinHandle<()>)> { let conn = self.conn(); trace!("Preparing VM for migration"); @@ -203,7 +203,7 @@ impl VMInstance { "Preparing VM for migration, spawning receiver in background" ); - tokio::spawn(async move { + let migration_task = tokio::spawn(async move { match conn .vm_receive_migration_put(receive_migration_data) .await @@ -221,7 +221,7 @@ impl VMInstance { } }); - Ok(receiver_uri) + Ok((receiver_uri, migration_task)) } pub fn runtime_root() -> PathBuf { @@ -514,6 +514,29 @@ impl VMInstance { Ok(()) } + /// Dry-apply a VM config without actually setting it in Cloud Hypervisor, + /// allowing for live migration of the VM. + pub async fn prep_config(&mut self, config: models::VmConfig) -> Result<()> { + self.vm_config = Some(config.clone()); + + info!(vm_id = self.vm_id(), "Preparing VM config for migration"); + // simply "transform" the config here without actually setting it in CH, the migrator will do that for us + + let mut transformed_config = config.clone(); + self.transformer + .transform(self.vm_id(), &mut transformed_config) + .wrap_err(eyre!( + "Failed to apply config transforms for VM {}", + self.vm_id() + ))?; + + self.hook_manager + .before_boot(self.vm_id(), &config.clone()) + .await?; + + Ok(()) + } + pub async fn delete_config(&self) -> Result<()> { let conn = self.conn(); trace!(vm_id = self.vm_id(), "Deleting VM via CH API"); diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 7756162..0ae755c 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -1,8 +1,13 @@ use crate::state::VMInstance; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; -use odorobo_shared::messages::create_vm::{DeleteVM, GetVMInfo, GetVMInfoReply, ShutdownVM}; +use odorobo_shared::messages::vm::{ + DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, + ShutdownVM, +}; +use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; +use tokio::task::JoinHandle; use tracing::{error, info, trace, warn}; /* use std::process::Command; @@ -12,17 +17,29 @@ let output = Command::new("echo") .output() .expect("Failed to execute command"); */ +/// A migration state that holds the listening address and VM config for a migration, +/// used to pass live migration data between actors. +pub struct MigrationState { + pub listening_address: String, + pub config: VmConfig, + /// The task handle for the migration process. + pub migration_task: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MigrationFinished; #[derive(RemoteActor)] pub struct VMActor { pub vmid: ulid::Ulid, /// path to the Cloud Hypervisor socket, in /run/odorobo/vms//ch.sock pub vm_instance: VMInstance, + pub migration_state: Option, } impl Actor for VMActor { - // tuple of VM ID and config - type Args = (ulid::Ulid, VmConfig); + // tuple of VM ID and optional config + type Args = (ulid::Ulid, Option); type Error = Report; #[tracing::instrument(skip_all)] @@ -46,11 +63,12 @@ impl Actor for VMActor { // .spawn()?; // tokio::spawn(async move { - let vminstance = VMInstance::spawn(&vmid.to_string(), Some(vm_config), None).await?; + let vminstance = VMInstance::spawn(&vmid.to_string(), vm_config, None).await?; Ok(Self { vmid, vm_instance: vminstance, + migration_state: None, }) } @@ -88,6 +106,7 @@ impl From for VMInstance { actor.vm_instance } } + #[remote_message] impl Message for VMActor { type Reply = GetVMInfoReply; @@ -103,6 +122,115 @@ impl Message for VMActor { } } +#[remote_message] +impl Message for VMActor { + type Reply = MigrateVMReceiveReply; + + async fn handle( + &mut self, + msg: MigrateVMReceive, + ctx: &mut Context, + ) -> Self::Reply { + // if there's a task already ongoing + if let Some(migration_state) = &self.migration_state { + return MigrateVMReceiveReply { + listening_address: migration_state.listening_address.clone(), + }; + } + + let prep_config = msg.config.clone(); + + // Start receiving migration on the destination VM (this actor) + // todo: handle unwrap properly + let (listening_address, migration_task) = self + .vm_instance + .receive_migration() + .await + .expect("sending migration request failed"); + + // create ongoing migration state + self.migration_state = Some(MigrationState { + migration_task: Some(migration_task), + listening_address: listening_address.clone(), + config: msg.config, + }); + + let actor_ref = ctx.actor_ref().clone(); + + let vmid = self.vmid; + + // now spawn a task for itself + // to actually prep the migration while we're receiving the migration stream + tokio::spawn(async move { + if let Err(err) = actor_ref + .tell(PrepMigration { + vmid, + config: prep_config, + }) + .await + { + error!( + ?err, + "failed to start migration prep on destination VM actor" + ); + } + }); + + // send migration finished notification in a separate task, after the prep is done + if let Some(migration_state) = self.migration_state.as_mut() { + // take the task value out and await that + if let Some(migration_task) = migration_state.migration_task.take() { + // NOTE: this is kinda scuffed + let actor_ref = ctx.actor_ref().clone(); + tokio::spawn(async move { + if let Err(err) = migration_task.await { + error!(?err, "migration task join failed"); + } + + if let Err(err) = actor_ref.tell(MigrationFinished).await { + error!(?err, "failed to notify actor that migration finished"); + } + }); + } + } + + MigrateVMReceiveReply { listening_address } + } +} + +#[remote_message] +impl Message for VMActor { + type Reply = (); + + async fn handle( + &mut self, + _msg: MigrationFinished, + _ctx: &mut Context, + ) -> Self::Reply { + if self.migration_state.take().is_some() { + // todo: post-migration cleanup + info!(vmid = %self.vmid, "migration finished, cleared migration state"); + } else { + warn!(vmid = %self.vmid, "received migration finished notification with no active migration state"); + } + } +} + +#[remote_message] +impl Message for VMActor { + type Reply = (); + + async fn handle( + &mut self, + msg: PrepMigration, + _ctx: &mut Context, + ) -> Self::Reply { + info!(vmid = %self.vmid, "PrepMigration handler invoked"); + // todo: prepare devices, volumes, and apply migrated config + self.vm_instance.prep_config(msg.config).await.unwrap(); + } +} + #[remote_message] impl Message for VMActor { type Reply = (); diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index 80c963e..6659f07 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -2,7 +2,7 @@ use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, V use kameo::prelude::*; use odorobo_agent::state::provisioning::actor::VMActor; use odorobo_shared::{ - messages::create_vm::{ + messages::vm::{ AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, ShutdownVMReply, }, diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 4cc056e..64c7795 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -4,7 +4,7 @@ use std::time::Duration; use kameo::prelude::*; use odorobo_agent::actor::AgentActor; use odorobo_agent::state::provisioning::actor::VMActor; -use odorobo_shared::messages::create_vm::*; +use odorobo_shared::messages::vm::*; use odorobo_shared::messages::{Ping, Pong}; use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index 9175da9..52fb585 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -15,7 +15,7 @@ use axum::{ extract::{Path, State}, }; use kameo::actor::ActorRef; -use odorobo_shared::messages::create_vm::{AgentListVMs, DeleteVM, ShutdownVM}; +use odorobo_shared::messages::vm::{AgentListVMs, DeleteVM, ShutdownVM}; pub fn router() -> ApiRouter> { ApiRouter::new() @@ -67,7 +67,7 @@ async fn debug_create_vm( Json(request): Json, ) -> impl IntoApiResponse { let ulid = ulid::Ulid::new(); - let message = odorobo_shared::messages::create_vm::CreateVM { + let message = odorobo_shared::messages::vm::CreateVM { vmid: ulid, config: request.vm_config, }; diff --git a/odorobo-shared/src/messages/mod.rs b/odorobo-shared/src/messages/mod.rs index c804d29..adc750d 100644 --- a/odorobo-shared/src/messages/mod.rs +++ b/odorobo-shared/src/messages/mod.rs @@ -1,7 +1,7 @@ use kameo::prelude::*; use serde::{Deserialize, Serialize}; -pub mod create_vm; +pub mod vm; pub mod debug; pub mod server_status; diff --git a/odorobo-shared/src/messages/create_vm.rs b/odorobo-shared/src/messages/vm.rs similarity index 66% rename from odorobo-shared/src/messages/create_vm.rs rename to odorobo-shared/src/messages/vm.rs index 33ef0b7..0b397d6 100644 --- a/odorobo-shared/src/messages/create_vm.rs +++ b/odorobo-shared/src/messages/vm.rs @@ -1,3 +1,4 @@ +//! VM-related messages use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; use serde::{Deserialize, Serialize}; @@ -9,8 +10,7 @@ use ulid::Ulid; /// Message to create a new VM /// /// VmConfig is a Cloud Hypervisor VM spec, containing the VM's full configuration (untransformed by odorobo) - -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct CreateVM { /// the ULID of the VM to create pub vmid: Ulid, @@ -28,6 +28,38 @@ pub struct CreateVMReply { pub config: Option, } +/// Message to delete a VM's config from the agent, shutting it down +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CloudHypervisorDeleteVMConfig { + pub vmid: Ulid, +} + +/// Message to migrate a VM to a destination +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct MigrateVMSend { + pub vmid: Ulid, + pub target: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct MigrateVMReceive { + pub vmid: Ulid, + pub config: VmConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PrepMigration { + pub vmid: Ulid, + pub config: VmConfig, +} + +/// Reply to a MigrateVMReceive message, containing the listening address of the VM +#[derive(Serialize, Deserialize, Debug, Clone, Reply)] +pub struct MigrateVMReceiveReply { + pub listening_address: String, +} + +/// Message to delete a VM #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeleteVM { pub vmid: Ulid, @@ -36,6 +68,7 @@ pub struct DeleteVM { #[derive(Serialize, Deserialize, Reply, Debug, Clone)] pub struct DeleteVMReply; +/// Shuts down a VM temporarily #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ShutdownVM { pub vmid: Ulid, @@ -44,6 +77,7 @@ pub struct ShutdownVM { #[derive(Serialize, Deserialize, Reply, Debug)] pub struct ShutdownVMReply; +/// List VMs on an agent #[derive(Serialize, Deserialize, Debug)] pub struct AgentListVMs; @@ -53,6 +87,7 @@ pub struct AgentListVMsReply { pub vms: Vec, } +/// Get VM info #[derive(Serialize, Deserialize, Debug)] pub struct GetVMInfo { pub vmid: Ulid, From f42f773d2d061e0d95b8dfb60e7e8874e475ec7e Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:21:38 +0700 Subject: [PATCH 082/189] dx: set up sccache and mold --- .github/workflows/build_x86.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 76aae89..8684096 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -8,6 +8,8 @@ on: env: CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" jobs: build: @@ -16,6 +18,10 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: rui314/setup-mold@v1 + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + - name: Build run: cargo build --verbose --release - name: Upload artifact From 808b2364c065296760b80a5321aa41754a0553c1 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:30:30 +0700 Subject: [PATCH 083/189] add check action --- .github/workflows/check.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..601d087 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,23 @@ +name: Build x86 + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: rui314/setup-mold@v1 + + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features From 8e072180932d4fc698b9ada4a893e7f9eddc513c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:31:33 +0700 Subject: [PATCH 084/189] build on all branches --- .github/workflows/build_riscv_rva23.yml | 2 -- .github/workflows/build_x86.yml | 2 -- .github/workflows/check.yml | 2 -- 3 files changed, 6 deletions(-) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index beca89c..06cbcb2 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -2,9 +2,7 @@ name: Build RISC V RVA23 via x86 cross-compile on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 8684096..0a95e00 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -2,9 +2,7 @@ name: Build x86 on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 601d087..6ceea3b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,9 +2,7 @@ name: Build x86 on: push: - branches: ["main"] pull_request: - branches: ["main"] env: CARGO_TERM_COLOR: always From 6727819189fcc7e43c33cbc58a488eed3047300f Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:32:30 +0700 Subject: [PATCH 085/189] oops --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6ceea3b..3b1d5bb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,4 +1,4 @@ -name: Build x86 +name: Check Clippy on: push: From 48d405d4a84c2fc06582d8e4114b4afdab966d95 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:38:02 +0700 Subject: [PATCH 086/189] actions: write --- .github/workflows/check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3b1d5bb..b338301 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -4,6 +4,10 @@ on: push: pull_request: +permissions: + contents: read + actions: write + env: CARGO_TERM_COLOR: always From b575c1f22847ddea5f6372ea17afa61ab7ad13a3 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:47:42 +0700 Subject: [PATCH 087/189] Update check.yml --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b338301..56c345c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: permissions: contents: read - actions: write + checks: write env: CARGO_TERM_COLOR: always From e4a304948d21fa5d2a8d05a5346d7cf8b2aad87e Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:51:06 +0700 Subject: [PATCH 088/189] ci(rva23): copy script directly in CI image --- Containerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Containerfile b/Containerfile index 8f6fe62..40d75dc 100644 --- a/Containerfile +++ b/Containerfile @@ -9,7 +9,8 @@ RUN rustup-init \ --profile minimal \ --target riscv64a23-unknown-linux-gnu \ -y +COPY scripts/zig-cc-rva23.sh /scripts/zig-cc-rva23.sh ENV CARGO_BUILD_TARGET="riscv64a23-unknown-linux-gnu" ENV PATH="/root/.cargo/bin:$PATH" -ENV CC_riscv64a23_unknown_linux_gnu="/code/scripts/zig-cc-rva23.sh" -ENV CARGO_TARGET_RISCV64A23_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C linker=scripts/zig-cc-rva23.sh" +ENV CC_riscv64a23_unknown_linux_gnu="/scripts/zig-cc-rva23.sh" +ENV CARGO_TARGET_RISCV64A23_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C linker=/scripts/zig-cc-rva23.sh" From f221e7b0f35837ef361105dcb23e9d40d2705600 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:55:27 +0700 Subject: [PATCH 089/189] use clippy instead --- .github/workflows/check.yml | 25 ---------------- .github/workflows/clippy.yml | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/clippy.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 56c345c..0000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Check Clippy - -on: - push: - pull_request: - -permissions: - contents: read - checks: write - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - uses: rui314/setup-mold@v1 - - - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --all-features diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml new file mode 100644 index 0000000..16b7c72 --- /dev/null +++ b/.github/workflows/clippy.yml @@ -0,0 +1,55 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# rust-clippy is a tool that runs a bunch of lints to catch common +# mistakes in your Rust code and help improve your Rust code. +# More details at https://github.com/rust-lang/rust-clippy +# and https://rust-lang.github.io/rust-clippy/ + +name: rust-clippy analyze + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '39 6 * * 6' + +jobs: + rust-clippy-analyze: + name: Run rust-clippy analyzing + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 + with: + profile: minimal + toolchain: stable + components: clippy + override: true + + - name: Install required cargo + run: cargo install clippy-sarif sarif-fmt + + - name: Run rust-clippy + run: + cargo clippy + --all-features + --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt + continue-on-error: true + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: rust-clippy-results.sarif + wait-for-processing: true From a1076b86e02090b6f7b5bc2e5be9a378fa8759b1 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:56:52 +0700 Subject: [PATCH 090/189] run analysis on all branches --- .github/workflows/clippy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 16b7c72..aa4a9cd 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -11,10 +11,7 @@ name: rust-clippy analyze on: push: - branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] schedule: - cron: '39 6 * * 6' From cc01cae91f3783779f5d29b547762589050b6740 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 03:58:44 +0700 Subject: [PATCH 091/189] also cache clippy lmao --- .github/workflows/clippy.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index aa4a9cd..a26186e 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -15,6 +15,12 @@ on: schedule: - cron: '39 6 * * 6' + +env: + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + jobs: rust-clippy-analyze: name: Run rust-clippy analyzing @@ -35,6 +41,12 @@ jobs: components: clippy override: true + + - uses: rui314/setup-mold@v1 + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Install required cargo run: cargo install clippy-sarif sarif-fmt From d7e569d2b285df7a3dc4747844b183d26b5138fe Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 04:01:39 +0700 Subject: [PATCH 092/189] use upload-artifact v7, do not zip artifacts --- .github/workflows/build_riscv_rva23.yml | 3 ++- .github/workflows/build_x86.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index 06cbcb2..c3dd037 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -26,8 +26,9 @@ jobs: - name: Build binaries run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: + archive: false name: odorobo_riscv_rva23 path: | target/riscv64a23-unknown-linux-gnu/release/odorobo-agent diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 0a95e00..c005d0d 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -23,8 +23,9 @@ jobs: - name: Build run: cargo build --verbose --release - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: + archive: false name: odorobo_x86 path: | target/release/odorobo-agent From c7430691afc40f8e8a286b831fc3c4b526ba8234 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 04:04:06 +0700 Subject: [PATCH 093/189] do SARIF v4 action --- .github/workflows/clippy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index a26186e..9da9138 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -58,7 +58,7 @@ jobs: continue-on-error: true - name: Upload analysis results to GitHub - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: rust-clippy-results.sarif wait-for-processing: true From aec68dec0afeb12575c73c596dc8e4929c86c4cb Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 04:07:59 +0700 Subject: [PATCH 094/189] upload artifacts separately --- .github/workflows/build_riscv_rva23.yml | 12 ++++++++++-- .github/workflows/build_x86.yml | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index c3dd037..70540ab 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -25,11 +25,19 @@ jobs: run: podman build -t odorobo-builder:rva23 . - name: Build binaries run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose - - name: Upload artifact + + + - name: Upload artifact - agent uses: actions/upload-artifact@v7 with: archive: false - name: odorobo_riscv_rva23 + name: odorobo-agent-riscv_rva23 path: | target/riscv64a23-unknown-linux-gnu/release/odorobo-agent + - name: Upload artifact - odoroboctl + uses: actions/upload-artifact@v7 + with: + archive: false + name: odoroboctl-riscv_rva23 + path: | target/riscv64a23-unknown-linux-gnu/release/odoroboctl diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index c005d0d..88cde71 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -22,13 +22,21 @@ jobs: - name: Build run: cargo build --verbose --release - - name: Upload artifact + - name: Upload artifact - agent uses: actions/upload-artifact@v7 with: archive: false - name: odorobo_x86 + name: odorobo-agent-x86 path: | target/release/odorobo-agent + + - name: Upload artifact - odoroboctl + uses: actions/upload-artifact@v7 + with: + archive: false + name: odoroboctl-x86 + path: | target/release/odoroboctl + - name: Run tests run: cargo test --verbose From 8472617bd7ebe0aabfa83ee603285f74c549b343 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 11:35:33 +0700 Subject: [PATCH 095/189] add CH schemas, rename storage driver --- Cargo.toml | 1 + docs/schemas/BalloonConfig.schema.json | 3 + docs/schemas/ConsoleConfig.schema.json | 3 + docs/schemas/ConsoleMode.schema.json | 3 + docs/schemas/CoreSchedulingMode.schema.json | 3 + docs/schemas/CpuAffinity.schema.json | 3 + docs/schemas/CpuFeatures.schema.json | 3 + docs/schemas/CpuTopology.schema.json | 3 + docs/schemas/CpusConfig.schema.json | 3 + docs/schemas/DebugConsoleConfig.schema.json | 3 + docs/schemas/DeviceConfig.schema.json | 3 + docs/schemas/DeviceNode.schema.json | 3 + docs/schemas/DiskConfig.schema.json | 3 + docs/schemas/FsConfig.schema.json | 3 + .../GenericVhostUserConfig.schema.json | 3 + docs/schemas/ImageType.schema.json | 3 + docs/schemas/LandlockConfig.schema.json | 3 + docs/schemas/LockGranularity.schema.json | 3 + docs/schemas/MemoryConfig.schema.json | 3 + docs/schemas/MemoryRestoreMode.schema.json | 3 + docs/schemas/MemoryZoneConfig.schema.json | 3 + docs/schemas/NetConfig.schema.json | 3 + docs/schemas/NumaConfig.schema.json | 3 + docs/schemas/NumaDistance.schema.json | 3 + docs/schemas/PayloadConfig.schema.json | 3 + docs/schemas/PciDeviceInfo.schema.json | 3 + docs/schemas/PciSegmentConfig.schema.json | 3 + docs/schemas/PlatformConfig.schema.json | 3 + docs/schemas/PmemConfig.schema.json | 3 + docs/schemas/RateLimitGroupConfig.schema.json | 3 + docs/schemas/RateLimiterConfig.schema.json | 3 + docs/schemas/ReceiveMigrationData.schema.json | 3 + docs/schemas/RestoreConfig.schema.json | 3 + docs/schemas/RngConfig.schema.json | 3 + docs/schemas/SendMigrationData.schema.json | 3 + docs/schemas/TimeoutStrategy.schema.json | 3 + docs/schemas/TokenBucket.schema.json | 3 + docs/schemas/TpmConfig.schema.json | 3 + docs/schemas/VdpaConfig.schema.json | 3 + docs/schemas/VirtQueueAffinity.schema.json | 3 + docs/schemas/VmAddUserDevice.schema.json | 3 + docs/schemas/VmConfig.schema.json | 3 + docs/schemas/VmCoredumpData.schema.json | 3 + docs/schemas/VmCounters.schema.json | 3 + docs/schemas/VmInfo.schema.json | 3 + docs/schemas/VmRemoveDevice.schema.json | 3 + docs/schemas/VmResize.schema.json | 3 + docs/schemas/VmResizeDisk.schema.json | 3 + docs/schemas/VmResizeZone.schema.json | 3 + docs/schemas/VmSnapshotConfig.schema.json | 3 + docs/schemas/VmState.schema.json | 3 + docs/schemas/VmmPingResponse.schema.json | 3 + docs/schemas/VsockConfig.schema.json | 3 + docs/schemas/cloud-hypervisor.json | 2028 +++++++++++++++++ odorobo-agent/src/state/transform/mod.rs | 2 +- .../src/state/transform/storage/file.rs | 4 +- .../src/state/transform/storage/iscsi.rs | 44 +- .../src/state/transform/storage/mod.rs | 16 +- .../src/state/transform/storage/rbd.rs | 4 +- 59 files changed, 2220 insertions(+), 35 deletions(-) create mode 100644 docs/schemas/BalloonConfig.schema.json create mode 100644 docs/schemas/ConsoleConfig.schema.json create mode 100644 docs/schemas/ConsoleMode.schema.json create mode 100644 docs/schemas/CoreSchedulingMode.schema.json create mode 100644 docs/schemas/CpuAffinity.schema.json create mode 100644 docs/schemas/CpuFeatures.schema.json create mode 100644 docs/schemas/CpuTopology.schema.json create mode 100644 docs/schemas/CpusConfig.schema.json create mode 100644 docs/schemas/DebugConsoleConfig.schema.json create mode 100644 docs/schemas/DeviceConfig.schema.json create mode 100644 docs/schemas/DeviceNode.schema.json create mode 100644 docs/schemas/DiskConfig.schema.json create mode 100644 docs/schemas/FsConfig.schema.json create mode 100644 docs/schemas/GenericVhostUserConfig.schema.json create mode 100644 docs/schemas/ImageType.schema.json create mode 100644 docs/schemas/LandlockConfig.schema.json create mode 100644 docs/schemas/LockGranularity.schema.json create mode 100644 docs/schemas/MemoryConfig.schema.json create mode 100644 docs/schemas/MemoryRestoreMode.schema.json create mode 100644 docs/schemas/MemoryZoneConfig.schema.json create mode 100644 docs/schemas/NetConfig.schema.json create mode 100644 docs/schemas/NumaConfig.schema.json create mode 100644 docs/schemas/NumaDistance.schema.json create mode 100644 docs/schemas/PayloadConfig.schema.json create mode 100644 docs/schemas/PciDeviceInfo.schema.json create mode 100644 docs/schemas/PciSegmentConfig.schema.json create mode 100644 docs/schemas/PlatformConfig.schema.json create mode 100644 docs/schemas/PmemConfig.schema.json create mode 100644 docs/schemas/RateLimitGroupConfig.schema.json create mode 100644 docs/schemas/RateLimiterConfig.schema.json create mode 100644 docs/schemas/ReceiveMigrationData.schema.json create mode 100644 docs/schemas/RestoreConfig.schema.json create mode 100644 docs/schemas/RngConfig.schema.json create mode 100644 docs/schemas/SendMigrationData.schema.json create mode 100644 docs/schemas/TimeoutStrategy.schema.json create mode 100644 docs/schemas/TokenBucket.schema.json create mode 100644 docs/schemas/TpmConfig.schema.json create mode 100644 docs/schemas/VdpaConfig.schema.json create mode 100644 docs/schemas/VirtQueueAffinity.schema.json create mode 100644 docs/schemas/VmAddUserDevice.schema.json create mode 100644 docs/schemas/VmConfig.schema.json create mode 100644 docs/schemas/VmCoredumpData.schema.json create mode 100644 docs/schemas/VmCounters.schema.json create mode 100644 docs/schemas/VmInfo.schema.json create mode 100644 docs/schemas/VmRemoveDevice.schema.json create mode 100644 docs/schemas/VmResize.schema.json create mode 100644 docs/schemas/VmResizeDisk.schema.json create mode 100644 docs/schemas/VmResizeZone.schema.json create mode 100644 docs/schemas/VmSnapshotConfig.schema.json create mode 100644 docs/schemas/VmState.schema.json create mode 100644 docs/schemas/VmmPingResponse.schema.json create mode 100644 docs/schemas/VsockConfig.schema.json create mode 100644 docs/schemas/cloud-hypervisor.json diff --git a/Cargo.toml b/Cargo.toml index d45be1f..5364146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ ahash = { version = "0.8.12", features = ["serde"] } ulid = { version = "1.2", features = ["serde", "uuid"] } # aide needs an older version of schemars schemars = "0.9" +dirs = "6.0.0" bytesize = { version = "2.3.1", features = ["serde"] } odorobo-shared = { path = "odorobo-shared" } odorobo-agent = { path = "odorobo-agent" } diff --git a/docs/schemas/BalloonConfig.schema.json b/docs/schemas/BalloonConfig.schema.json new file mode 100644 index 0000000..3f94ff4 --- /dev/null +++ b/docs/schemas/BalloonConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/BalloonConfig" +} diff --git a/docs/schemas/ConsoleConfig.schema.json b/docs/schemas/ConsoleConfig.schema.json new file mode 100644 index 0000000..02caefb --- /dev/null +++ b/docs/schemas/ConsoleConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/ConsoleConfig" +} diff --git a/docs/schemas/ConsoleMode.schema.json b/docs/schemas/ConsoleMode.schema.json new file mode 100644 index 0000000..7da8018 --- /dev/null +++ b/docs/schemas/ConsoleMode.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/ConsoleMode" +} diff --git a/docs/schemas/CoreSchedulingMode.schema.json b/docs/schemas/CoreSchedulingMode.schema.json new file mode 100644 index 0000000..d34d007 --- /dev/null +++ b/docs/schemas/CoreSchedulingMode.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/CoreSchedulingMode" +} diff --git a/docs/schemas/CpuAffinity.schema.json b/docs/schemas/CpuAffinity.schema.json new file mode 100644 index 0000000..5ab1fae --- /dev/null +++ b/docs/schemas/CpuAffinity.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/CpuAffinity" +} diff --git a/docs/schemas/CpuFeatures.schema.json b/docs/schemas/CpuFeatures.schema.json new file mode 100644 index 0000000..9855b4b --- /dev/null +++ b/docs/schemas/CpuFeatures.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/CpuFeatures" +} diff --git a/docs/schemas/CpuTopology.schema.json b/docs/schemas/CpuTopology.schema.json new file mode 100644 index 0000000..394c525 --- /dev/null +++ b/docs/schemas/CpuTopology.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/CpuTopology" +} diff --git a/docs/schemas/CpusConfig.schema.json b/docs/schemas/CpusConfig.schema.json new file mode 100644 index 0000000..cafbfb2 --- /dev/null +++ b/docs/schemas/CpusConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/CpusConfig" +} diff --git a/docs/schemas/DebugConsoleConfig.schema.json b/docs/schemas/DebugConsoleConfig.schema.json new file mode 100644 index 0000000..0b4fb16 --- /dev/null +++ b/docs/schemas/DebugConsoleConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/DebugConsoleConfig" +} diff --git a/docs/schemas/DeviceConfig.schema.json b/docs/schemas/DeviceConfig.schema.json new file mode 100644 index 0000000..079b679 --- /dev/null +++ b/docs/schemas/DeviceConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/DeviceConfig" +} diff --git a/docs/schemas/DeviceNode.schema.json b/docs/schemas/DeviceNode.schema.json new file mode 100644 index 0000000..ad50431 --- /dev/null +++ b/docs/schemas/DeviceNode.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/DeviceNode" +} diff --git a/docs/schemas/DiskConfig.schema.json b/docs/schemas/DiskConfig.schema.json new file mode 100644 index 0000000..ff96972 --- /dev/null +++ b/docs/schemas/DiskConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/DiskConfig" +} diff --git a/docs/schemas/FsConfig.schema.json b/docs/schemas/FsConfig.schema.json new file mode 100644 index 0000000..8588a70 --- /dev/null +++ b/docs/schemas/FsConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/FsConfig" +} diff --git a/docs/schemas/GenericVhostUserConfig.schema.json b/docs/schemas/GenericVhostUserConfig.schema.json new file mode 100644 index 0000000..1db4296 --- /dev/null +++ b/docs/schemas/GenericVhostUserConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/GenericVhostUserConfig" +} diff --git a/docs/schemas/ImageType.schema.json b/docs/schemas/ImageType.schema.json new file mode 100644 index 0000000..e06f10f --- /dev/null +++ b/docs/schemas/ImageType.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/ImageType" +} diff --git a/docs/schemas/LandlockConfig.schema.json b/docs/schemas/LandlockConfig.schema.json new file mode 100644 index 0000000..4a6b657 --- /dev/null +++ b/docs/schemas/LandlockConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/LandlockConfig" +} diff --git a/docs/schemas/LockGranularity.schema.json b/docs/schemas/LockGranularity.schema.json new file mode 100644 index 0000000..7833553 --- /dev/null +++ b/docs/schemas/LockGranularity.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/LockGranularity" +} diff --git a/docs/schemas/MemoryConfig.schema.json b/docs/schemas/MemoryConfig.schema.json new file mode 100644 index 0000000..9787857 --- /dev/null +++ b/docs/schemas/MemoryConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/MemoryConfig" +} diff --git a/docs/schemas/MemoryRestoreMode.schema.json b/docs/schemas/MemoryRestoreMode.schema.json new file mode 100644 index 0000000..bbc3f29 --- /dev/null +++ b/docs/schemas/MemoryRestoreMode.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/MemoryRestoreMode" +} diff --git a/docs/schemas/MemoryZoneConfig.schema.json b/docs/schemas/MemoryZoneConfig.schema.json new file mode 100644 index 0000000..9bf43f8 --- /dev/null +++ b/docs/schemas/MemoryZoneConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/MemoryZoneConfig" +} diff --git a/docs/schemas/NetConfig.schema.json b/docs/schemas/NetConfig.schema.json new file mode 100644 index 0000000..cbd17ba --- /dev/null +++ b/docs/schemas/NetConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/NetConfig" +} diff --git a/docs/schemas/NumaConfig.schema.json b/docs/schemas/NumaConfig.schema.json new file mode 100644 index 0000000..0a5818b --- /dev/null +++ b/docs/schemas/NumaConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/NumaConfig" +} diff --git a/docs/schemas/NumaDistance.schema.json b/docs/schemas/NumaDistance.schema.json new file mode 100644 index 0000000..4b686ff --- /dev/null +++ b/docs/schemas/NumaDistance.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/NumaDistance" +} diff --git a/docs/schemas/PayloadConfig.schema.json b/docs/schemas/PayloadConfig.schema.json new file mode 100644 index 0000000..23c7773 --- /dev/null +++ b/docs/schemas/PayloadConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/PayloadConfig" +} diff --git a/docs/schemas/PciDeviceInfo.schema.json b/docs/schemas/PciDeviceInfo.schema.json new file mode 100644 index 0000000..814f5ea --- /dev/null +++ b/docs/schemas/PciDeviceInfo.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/PciDeviceInfo" +} diff --git a/docs/schemas/PciSegmentConfig.schema.json b/docs/schemas/PciSegmentConfig.schema.json new file mode 100644 index 0000000..40383f0 --- /dev/null +++ b/docs/schemas/PciSegmentConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/PciSegmentConfig" +} diff --git a/docs/schemas/PlatformConfig.schema.json b/docs/schemas/PlatformConfig.schema.json new file mode 100644 index 0000000..9b8edd3 --- /dev/null +++ b/docs/schemas/PlatformConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/PlatformConfig" +} diff --git a/docs/schemas/PmemConfig.schema.json b/docs/schemas/PmemConfig.schema.json new file mode 100644 index 0000000..0e3bf97 --- /dev/null +++ b/docs/schemas/PmemConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/PmemConfig" +} diff --git a/docs/schemas/RateLimitGroupConfig.schema.json b/docs/schemas/RateLimitGroupConfig.schema.json new file mode 100644 index 0000000..555f47d --- /dev/null +++ b/docs/schemas/RateLimitGroupConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/RateLimitGroupConfig" +} diff --git a/docs/schemas/RateLimiterConfig.schema.json b/docs/schemas/RateLimiterConfig.schema.json new file mode 100644 index 0000000..a28acd9 --- /dev/null +++ b/docs/schemas/RateLimiterConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/RateLimiterConfig" +} diff --git a/docs/schemas/ReceiveMigrationData.schema.json b/docs/schemas/ReceiveMigrationData.schema.json new file mode 100644 index 0000000..fa3c197 --- /dev/null +++ b/docs/schemas/ReceiveMigrationData.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/ReceiveMigrationData" +} diff --git a/docs/schemas/RestoreConfig.schema.json b/docs/schemas/RestoreConfig.schema.json new file mode 100644 index 0000000..fc11368 --- /dev/null +++ b/docs/schemas/RestoreConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/RestoreConfig" +} diff --git a/docs/schemas/RngConfig.schema.json b/docs/schemas/RngConfig.schema.json new file mode 100644 index 0000000..07f01f3 --- /dev/null +++ b/docs/schemas/RngConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/RngConfig" +} diff --git a/docs/schemas/SendMigrationData.schema.json b/docs/schemas/SendMigrationData.schema.json new file mode 100644 index 0000000..b8755a1 --- /dev/null +++ b/docs/schemas/SendMigrationData.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/SendMigrationData" +} diff --git a/docs/schemas/TimeoutStrategy.schema.json b/docs/schemas/TimeoutStrategy.schema.json new file mode 100644 index 0000000..8434cc4 --- /dev/null +++ b/docs/schemas/TimeoutStrategy.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/TimeoutStrategy" +} diff --git a/docs/schemas/TokenBucket.schema.json b/docs/schemas/TokenBucket.schema.json new file mode 100644 index 0000000..f6a83aa --- /dev/null +++ b/docs/schemas/TokenBucket.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/TokenBucket" +} diff --git a/docs/schemas/TpmConfig.schema.json b/docs/schemas/TpmConfig.schema.json new file mode 100644 index 0000000..a9d6344 --- /dev/null +++ b/docs/schemas/TpmConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/TpmConfig" +} diff --git a/docs/schemas/VdpaConfig.schema.json b/docs/schemas/VdpaConfig.schema.json new file mode 100644 index 0000000..3609b17 --- /dev/null +++ b/docs/schemas/VdpaConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VdpaConfig" +} diff --git a/docs/schemas/VirtQueueAffinity.schema.json b/docs/schemas/VirtQueueAffinity.schema.json new file mode 100644 index 0000000..837153a --- /dev/null +++ b/docs/schemas/VirtQueueAffinity.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VirtQueueAffinity" +} diff --git a/docs/schemas/VmAddUserDevice.schema.json b/docs/schemas/VmAddUserDevice.schema.json new file mode 100644 index 0000000..7c8f9a7 --- /dev/null +++ b/docs/schemas/VmAddUserDevice.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmAddUserDevice" +} diff --git a/docs/schemas/VmConfig.schema.json b/docs/schemas/VmConfig.schema.json new file mode 100644 index 0000000..dbe8b3c --- /dev/null +++ b/docs/schemas/VmConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmConfig" +} diff --git a/docs/schemas/VmCoredumpData.schema.json b/docs/schemas/VmCoredumpData.schema.json new file mode 100644 index 0000000..697f0ce --- /dev/null +++ b/docs/schemas/VmCoredumpData.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmCoredumpData" +} diff --git a/docs/schemas/VmCounters.schema.json b/docs/schemas/VmCounters.schema.json new file mode 100644 index 0000000..cea238a --- /dev/null +++ b/docs/schemas/VmCounters.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmCounters" +} diff --git a/docs/schemas/VmInfo.schema.json b/docs/schemas/VmInfo.schema.json new file mode 100644 index 0000000..23fa388 --- /dev/null +++ b/docs/schemas/VmInfo.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmInfo" +} diff --git a/docs/schemas/VmRemoveDevice.schema.json b/docs/schemas/VmRemoveDevice.schema.json new file mode 100644 index 0000000..1225098 --- /dev/null +++ b/docs/schemas/VmRemoveDevice.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmRemoveDevice" +} diff --git a/docs/schemas/VmResize.schema.json b/docs/schemas/VmResize.schema.json new file mode 100644 index 0000000..8678eef --- /dev/null +++ b/docs/schemas/VmResize.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmResize" +} diff --git a/docs/schemas/VmResizeDisk.schema.json b/docs/schemas/VmResizeDisk.schema.json new file mode 100644 index 0000000..b88b1fc --- /dev/null +++ b/docs/schemas/VmResizeDisk.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmResizeDisk" +} diff --git a/docs/schemas/VmResizeZone.schema.json b/docs/schemas/VmResizeZone.schema.json new file mode 100644 index 0000000..eebdc8a --- /dev/null +++ b/docs/schemas/VmResizeZone.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmResizeZone" +} diff --git a/docs/schemas/VmSnapshotConfig.schema.json b/docs/schemas/VmSnapshotConfig.schema.json new file mode 100644 index 0000000..6e5be3e --- /dev/null +++ b/docs/schemas/VmSnapshotConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmSnapshotConfig" +} diff --git a/docs/schemas/VmState.schema.json b/docs/schemas/VmState.schema.json new file mode 100644 index 0000000..53c37d2 --- /dev/null +++ b/docs/schemas/VmState.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmState" +} diff --git a/docs/schemas/VmmPingResponse.schema.json b/docs/schemas/VmmPingResponse.schema.json new file mode 100644 index 0000000..997a6ae --- /dev/null +++ b/docs/schemas/VmmPingResponse.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VmmPingResponse" +} diff --git a/docs/schemas/VsockConfig.schema.json b/docs/schemas/VsockConfig.schema.json new file mode 100644 index 0000000..b5856c9 --- /dev/null +++ b/docs/schemas/VsockConfig.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "cloud-hypervisor.json#/components/schemas/VsockConfig" +} diff --git a/docs/schemas/cloud-hypervisor.json b/docs/schemas/cloud-hypervisor.json new file mode 100644 index 0000000..b2c7d6e --- /dev/null +++ b/docs/schemas/cloud-hypervisor.json @@ -0,0 +1,2028 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Cloud Hypervisor API", + "description": "Local HTTP based API for managing and inspecting a cloud-hypervisor virtual machine.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "0.3.0" + }, + "servers": [ + { + "url": "http://localhost/api/v1" + } + ], + "paths": { + "/vmm.ping": { + "get": { + "summary": "Ping the VMM to check for API server availability", + "responses": { + "200": { + "description": "The VMM information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmmPingResponse" + } + } + } + } + } + } + }, + "/vmm.shutdown": { + "put": { + "summary": "Shuts the cloud-hypervisor VMM.", + "operationId": "shutdownVMM", + "responses": { + "204": { + "description": "The VMM successfully shutdown." + } + } + } + }, + "/vm.info": { + "get": { + "summary": "Returns general information about the cloud-hypervisor Virtual Machine (VM) instance.", + "responses": { + "200": { + "description": "The VM information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmInfo" + } + } + } + } + } + } + }, + "/vm.counters": { + "get": { + "summary": "Get counters from the VM", + "responses": { + "200": { + "description": "The VM counters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmCounters" + } + } + } + } + } + } + }, + "/vm.create": { + "put": { + "summary": "Create the cloud-hypervisor Virtual Machine (VM) instance. The instance is not booted, only created.", + "operationId": "createVM", + "requestBody": { + "description": "The VM configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM instance was successfully created." + } + } + } + }, + "/vm.delete": { + "put": { + "summary": "Delete the cloud-hypervisor Virtual Machine (VM) instance.", + "operationId": "deleteVM", + "responses": { + "204": { + "description": "The VM instance was successfully deleted." + } + } + } + }, + "/vm.boot": { + "put": { + "summary": "Boot the previously created VM instance.", + "operationId": "bootVM", + "responses": { + "204": { + "description": "The VM instance successfully booted." + }, + "404": { + "description": "The VM instance could not boot because it is not created yet" + } + } + } + }, + "/vm.pause": { + "put": { + "summary": "Pause a previously booted VM instance.", + "operationId": "pauseVM", + "responses": { + "204": { + "description": "The VM instance successfully paused." + }, + "404": { + "description": "The VM instance could not pause because it is not created yet" + }, + "405": { + "description": "The VM instance could not pause because it is not booted." + } + } + } + }, + "/vm.resume": { + "put": { + "summary": "Resume a previously paused VM instance.", + "operationId": "resumeVM", + "responses": { + "204": { + "description": "The VM instance successfully paused." + }, + "404": { + "description": "The VM instance could not resume because it is not booted yet" + }, + "405": { + "description": "The VM instance could not resume because it is not paused." + } + } + } + }, + "/vm.shutdown": { + "put": { + "summary": "Shut the VM instance down.", + "operationId": "shutdownVM", + "responses": { + "204": { + "description": "The VM instance successfully shut down." + }, + "404": { + "description": "The VM instance could not shut down because is not created." + }, + "405": { + "description": "The VM instance could not shut down because it is not started." + } + } + } + }, + "/vm.reboot": { + "put": { + "summary": "Reboot the VM instance.", + "operationId": "rebootVM", + "responses": { + "204": { + "description": "The VM instance successfully rebooted." + }, + "404": { + "description": "The VM instance could not reboot because it is not created." + }, + "405": { + "description": "The VM instance could not reboot because it is not booted." + } + } + } + }, + "/vm.power-button": { + "put": { + "summary": "Trigger a power button in the VM", + "operationId": "power-buttonVM", + "responses": { + "204": { + "description": "Power button successfully triggered in the VM" + }, + "404": { + "description": "The button could not be triggered because it is not created yet" + }, + "405": { + "description": "The button could not be triggered because it is not booted." + } + } + } + }, + "/vm.resize": { + "put": { + "summary": "Resize the VM", + "requestBody": { + "description": "The target size for the VM", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmResize" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM instance was successfully resized." + }, + "404": { + "description": "The VM instance could not be resized because it is not created." + }, + "429": { + "description": "The VM instance could not be resized because a cpu removal is still pending." + } + } + } + }, + "/vm.resize-disk": { + "put": { + "summary": "Resize a disk", + "requestBody": { + "description": "Resizes a disk attached to the VM", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmResizeDisk" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The disk was successfully resized." + }, + "500": { + "description": "The disk could not be resized." + } + } + } + }, + "/vm.resize-zone": { + "put": { + "summary": "Resize a memory zone", + "requestBody": { + "description": "The target size for the memory zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmResizeZone" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The memory zone was successfully resized." + }, + "500": { + "description": "The memory zone could not be resized." + } + } + } + }, + "/vm.add-device": { + "put": { + "summary": "Add a new device to the VM", + "requestBody": { + "description": "The path of the new device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "404": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.remove-device": { + "put": { + "summary": "Remove a device from the VM", + "requestBody": { + "description": "The identifier of the device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmRemoveDevice" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The device was successfully removed from the VM instance." + }, + "404": { + "description": "The device could not be removed from the VM instance." + } + } + } + }, + "/vm.add-disk": { + "put": { + "summary": "Add a new disk to the VM", + "requestBody": { + "description": "The details of the new disk", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiskConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new disk was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new disk was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new disk could not be added to the VM instance." + } + } + } + }, + "/vm.add-fs": { + "put": { + "summary": "Add a new virtio-fs device to the VM", + "requestBody": { + "description": "The details of the new virtio-fs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.add-generic-vhost-user": { + "put": { + "summary": "Add a new generic vhost-user device to the VM", + "requestBody": { + "description": "The details of the new generic vhost-user device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericVhostUserConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.add-pmem": { + "put": { + "summary": "Add a new pmem device to the VM", + "requestBody": { + "description": "The details of the new pmem device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PmemConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.add-net": { + "put": { + "summary": "Add a new network device to the VM", + "requestBody": { + "description": "The details of the new network device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.add-vsock": { + "put": { + "summary": "Add a new vsock device to the VM", + "requestBody": { + "description": "The details of the new vsock device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VsockConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new device could not be added to the VM instance." + } + } + } + }, + "/vm.add-vdpa": { + "put": { + "summary": "Add a new vDPA device to the VM", + "requestBody": { + "description": "The details of the new vDPA device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VdpaConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The new vDPA device was successfully added to the VM instance.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + } + }, + "204": { + "description": "The new vDPA device was successfully (cold) added to the VM instance." + }, + "500": { + "description": "The new vDPA device could not be added to the VM instance." + } + } + } + }, + "/vm.add-user-device": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmAddUserDevice" + } + } + }, + "description": "The path of the new device", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PciDeviceInfo" + } + } + }, + "description": "The new device was successfully added to the VM instance." + }, + "204": { + "description": "The new device was successfully (cold) added to the VM instance." + }, + "404": { + "description": "The new device could not be added to the VM instance." + } + }, + "summary": "Add a new userspace device to the VM" + } + }, + "/vm.snapshot": { + "put": { + "summary": "Returns a VM snapshot.", + "requestBody": { + "description": "The snapshot configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmSnapshotConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM instance was successfully snapshotted." + }, + "404": { + "description": "The VM instance could not be snapshotted because it is not created." + }, + "405": { + "description": "The VM instance could not be snapshotted because it is not booted." + } + } + } + }, + "/vm.coredump": { + "put": { + "summary": "Takes a VM coredump.", + "requestBody": { + "description": "The coredump configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VmCoredumpData" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM instance was successfully coredumped." + }, + "404": { + "description": "The VM instance could not be coredumped because it is not created." + }, + "405": { + "description": "The VM instance could not be coredumped because it is not booted." + } + } + } + }, + "/vmm.nmi": { + "put": { + "summary": "Inject an NMI.", + "responses": { + "204": { + "description": "The NMI successfully injected." + } + } + } + }, + "/vm.restore": { + "put": { + "summary": "Restore a VM from a snapshot.", + "requestBody": { + "description": "The restore configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM instance was successfully restored." + }, + "404": { + "description": "The VM instance could not be restored because it is already created." + } + } + } + }, + "/vm.receive-migration": { + "put": { + "summary": "Receive a VM migration from URL", + "requestBody": { + "description": "The URL for the reception of migration state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiveMigrationData" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM migration was successfully received." + }, + "500": { + "description": "The VM migration could not be received." + } + } + } + }, + "/vm.send-migration": { + "put": { + "summary": "Send a VM migration to URL", + "requestBody": { + "description": "The URL for sending the migration state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMigrationData" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The VM migration was successfully sent." + }, + "500": { + "description": "The VM migration could not be sent." + } + } + } + } + }, + "components": { + "schemas": { + "VmmPingResponse": { + "required": [ + "version" + ], + "type": "object", + "properties": { + "build_version": { + "type": "string" + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "format": "int64" + }, + "features": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "Virtual Machine Monitor information" + }, + "VmState": { + "type": "string", + "enum": [ + "Created", + "Running", + "Shutdown", + "Paused" + ] + }, + "VmInfo": { + "required": [ + "config", + "state" + ], + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/VmConfig" + }, + "state": { + "$ref": "#/components/schemas/VmState" + }, + "memory_actual_size": { + "type": "integer", + "format": "int64" + }, + "device_tree": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/DeviceNode" + } + } + }, + "description": "Virtual Machine information" + }, + "DeviceNode": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + } + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "pci_bdf": { + "type": "string" + } + } + }, + "VmCounters": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + }, + "PciDeviceInfo": { + "required": [ + "id", + "bdf" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "bdf": { + "type": "string" + } + }, + "description": "Information about a PCI device" + }, + "PayloadConfig": { + "type": "object", + "properties": { + "firmware": { + "type": "string" + }, + "kernel": { + "type": "string" + }, + "cmdline": { + "type": "string" + }, + "initramfs": { + "type": "string" + }, + "igvm": { + "type": "string" + }, + "host_data": { + "type": "string" + } + }, + "description": "Payloads to boot in guest" + }, + "VmConfig": { + "required": [ + "payload" + ], + "type": "object", + "properties": { + "cpus": { + "$ref": "#/components/schemas/CpusConfig" + }, + "memory": { + "$ref": "#/components/schemas/MemoryConfig" + }, + "payload": { + "$ref": "#/components/schemas/PayloadConfig" + }, + "rate_limit_groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RateLimitGroupConfig" + } + }, + "disks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiskConfig" + } + }, + "net": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetConfig" + } + }, + "rng": { + "$ref": "#/components/schemas/RngConfig" + }, + "balloon": { + "$ref": "#/components/schemas/BalloonConfig" + }, + "fs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsConfig" + } + }, + "generic-vhost-user": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenericVhostUserConfig" + } + }, + "pmem": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PmemConfig" + } + }, + "serial": { + "$ref": "#/components/schemas/ConsoleConfig" + }, + "console": { + "$ref": "#/components/schemas/ConsoleConfig" + }, + "debug_console": { + "$ref": "#/components/schemas/DebugConsoleConfig" + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceConfig" + } + }, + "vdpa": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VdpaConfig" + } + }, + "vsock": { + "$ref": "#/components/schemas/VsockConfig" + }, + "numa": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NumaConfig" + } + }, + "iommu": { + "type": "boolean", + "default": false + }, + "watchdog": { + "type": "boolean", + "default": false + }, + "pvpanic": { + "type": "boolean", + "default": false + }, + "pci_segments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PciSegmentConfig" + } + }, + "platform": { + "$ref": "#/components/schemas/PlatformConfig" + }, + "tpm": { + "$ref": "#/components/schemas/TpmConfig" + }, + "landlock_enable": { + "type": "boolean", + "default": false + }, + "landlock_rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LandlockConfig" + } + } + }, + "description": "Virtual machine configuration" + }, + "CpuAffinity": { + "required": [ + "vcpu", + "host_cpus" + ], + "type": "object", + "properties": { + "vcpu": { + "type": "integer" + }, + "host_cpus": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "CpuFeatures": { + "type": "object", + "properties": { + "amx": { + "type": "boolean" + } + } + }, + "CpuTopology": { + "type": "object", + "properties": { + "threads_per_core": { + "type": "integer" + }, + "cores_per_die": { + "type": "integer" + }, + "dies_per_package": { + "type": "integer" + }, + "packages": { + "type": "integer" + } + } + }, + "CoreSchedulingMode": { + "type": "string", + "enum": [ + "Vm", + "Vcpu", + "Off" + ], + "default": "Vm" + }, + "CpusConfig": { + "required": [ + "boot_vcpus", + "max_vcpus" + ], + "type": "object", + "properties": { + "boot_vcpus": { + "minimum": 1, + "type": "integer" + }, + "max_vcpus": { + "minimum": 1, + "type": "integer" + }, + "topology": { + "$ref": "#/components/schemas/CpuTopology" + }, + "kvm_hyperv": { + "type": "boolean", + "default": false + }, + "max_phys_bits": { + "type": "integer" + }, + "nested": { + "type": "boolean", + "default": true + }, + "affinity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CpuAffinity" + } + }, + "features": { + "$ref": "#/components/schemas/CpuFeatures" + }, + "core_scheduling": { + "$ref": "#/components/schemas/CoreSchedulingMode" + } + } + }, + "PciSegmentConfig": { + "required": [ + "pci_segment" + ], + "type": "object", + "properties": { + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "mmio32_aperture_weight": { + "type": "integer", + "format": "int32" + }, + "mmio64_aperture_weight": { + "type": "integer", + "format": "int32" + } + } + }, + "PlatformConfig": { + "type": "object", + "properties": { + "num_pci_segments": { + "type": "integer", + "format": "int16" + }, + "iommu_segments": { + "type": "array", + "items": { + "type": "integer", + "format": "int16" + } + }, + "iommu_address_width": { + "type": "integer", + "format": "uint8" + }, + "serial_number": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "oem_strings": { + "type": "array", + "items": { + "type": "string" + } + }, + "tdx": { + "type": "boolean", + "default": false + }, + "sev_snp": { + "type": "boolean", + "default": false + }, + "iommufd": { + "type": "boolean", + "default": false + }, + "vfio_p2p_dma": { + "type": "boolean", + "default": true + } + } + }, + "MemoryZoneConfig": { + "required": [ + "id", + "size" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "file": { + "type": "string" + }, + "mergeable": { + "type": "boolean", + "default": false + }, + "shared": { + "type": "boolean", + "default": false + }, + "hugepages": { + "type": "boolean", + "default": false + }, + "hugepage_size": { + "type": "integer", + "format": "int64" + }, + "host_numa_node": { + "type": "integer", + "format": "int32" + }, + "hotplug_size": { + "type": "integer", + "format": "int64" + }, + "hotplugged_size": { + "type": "integer", + "format": "int64" + }, + "prefault": { + "type": "boolean", + "default": false + } + } + }, + "MemoryConfig": { + "required": [ + "size" + ], + "type": "object", + "properties": { + "size": { + "type": "integer", + "format": "int64" + }, + "hotplug_size": { + "type": "integer", + "format": "int64" + }, + "hotplugged_size": { + "type": "integer", + "format": "int64" + }, + "mergeable": { + "type": "boolean", + "default": false + }, + "hotplug_method": { + "type": "string", + "default": "Acpi" + }, + "shared": { + "type": "boolean", + "default": false + }, + "hugepages": { + "type": "boolean", + "default": false + }, + "hugepage_size": { + "type": "integer", + "format": "int64" + }, + "prefault": { + "type": "boolean", + "default": false + }, + "thp": { + "type": "boolean", + "default": true + }, + "zones": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoryZoneConfig" + } + } + } + }, + "TokenBucket": { + "required": [ + "size", + "refill_time" + ], + "type": "object", + "properties": { + "size": { + "type": "integer", + "format": "int64", + "minimum": 0, + "description": "The total number of tokens this bucket can hold." + }, + "one_time_burst": { + "type": "integer", + "format": "int64", + "minimum": 0, + "description": "The initial size of a token bucket." + }, + "refill_time": { + "type": "integer", + "format": "int64", + "minimum": 0, + "description": "The amount of milliseconds it takes for the bucket to refill." + } + }, + "description": "Defines a token bucket with a maximum capacity (_size_), an initial burst size (_one_time_burst_) and an interval for refilling purposes (_refill_time_). The refill-rate is derived from _size_ and _refill_time_, and it is the constant rate at which the tokens replenish. The refill process only starts happening after the initial burst budget is consumed. Consumption from the token bucket is unbounded in speed which allows for bursts bound in size by the amount of tokens available. Once the token bucket is empty, consumption speed is bound by the refill-rate." + }, + "RateLimiterConfig": { + "type": "object", + "properties": { + "bandwidth": { + "$ref": "#/components/schemas/TokenBucket" + }, + "ops": { + "$ref": "#/components/schemas/TokenBucket" + } + }, + "description": "Defines an IO rate limiter with independent bytes/s and ops/s limits. Limits are defined by configuring each of the _bandwidth_ and _ops_ token buckets." + }, + "RateLimitGroupConfig": { + "required": [ + "id", + "rate_limiter_config" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "rate_limiter_config": { + "$ref": "#/components/schemas/RateLimiterConfig" + } + } + }, + "VirtQueueAffinity": { + "required": [ + "queue_index", + "host_cpus" + ], + "type": "object", + "properties": { + "queue_index": { + "type": "integer" + }, + "host_cpus": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "ImageType": { + "type": "string", + "enum": [ + "FixedVhd", + "Qcow2", + "Raw", + "Vhdx", + "Unknown" + ] + }, + "LockGranularity": { + "type": "string", + "enum": [ + "ByteRange", + "Full" + ], + "default": "ByteRange" + }, + "DiskConfig": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "readonly": { + "type": "boolean", + "default": false + }, + "direct": { + "type": "boolean", + "default": false + }, + "iommu": { + "type": "boolean", + "default": false + }, + "num_queues": { + "type": "integer", + "default": 1 + }, + "queue_size": { + "type": "integer", + "default": 128 + }, + "vhost_user": { + "type": "boolean", + "default": false + }, + "vhost_socket": { + "type": "string" + }, + "rate_limiter_config": { + "$ref": "#/components/schemas/RateLimiterConfig" + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + }, + "serial": { + "type": "string" + }, + "rate_limit_group": { + "type": "string" + }, + "queue_affinity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VirtQueueAffinity" + } + }, + "backing_files": { + "type": "boolean", + "default": false + }, + "sparse": { + "type": "boolean", + "default": true + }, + "image_type": { + "$ref": "#/components/schemas/ImageType" + }, + "lock_granularity": { + "$ref": "#/components/schemas/LockGranularity" + } + } + }, + "NetConfig": { + "type": "object", + "properties": { + "tap": { + "type": "string" + }, + "ip": { + "type": "string", + "default": "192.168.249.1", + "description": "IPv4 or IPv6 address" + }, + "mask": { + "type": "string", + "default": "255.255.255.0", + "description": "Must be a valid IPv4 netmask if ip is an IPv4 address or a valid IPv6 netmask if ip is an IPv6 address." + }, + "mac": { + "type": "string" + }, + "host_mac": { + "type": "string" + }, + "mtu": { + "type": "integer" + }, + "iommu": { + "type": "boolean", + "default": false + }, + "num_queues": { + "type": "integer", + "default": 2 + }, + "queue_size": { + "type": "integer", + "default": 256 + }, + "vhost_user": { + "type": "boolean", + "default": false + }, + "vhost_socket": { + "type": "string" + }, + "vhost_mode": { + "type": "string", + "default": "Client" + }, + "id": { + "type": "string" + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "rate_limiter_config": { + "$ref": "#/components/schemas/RateLimiterConfig" + }, + "offload_tso": { + "type": "boolean", + "default": true + }, + "offload_ufo": { + "type": "boolean", + "default": true + }, + "offload_csum": { + "type": "boolean", + "default": true + } + } + }, + "RngConfig": { + "required": [ + "src" + ], + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "iommu": { + "type": "boolean", + "default": false + } + } + }, + "BalloonConfig": { + "required": [ + "size" + ], + "type": "object", + "properties": { + "size": { + "type": "integer", + "format": "int64" + }, + "deflate_on_oom": { + "type": "boolean", + "default": false, + "description": "Deflate balloon when the guest is under memory pressure." + }, + "free_page_reporting": { + "type": "boolean", + "default": false, + "description": "Enable guest to report free pages." + } + } + }, + "FsConfig": { + "required": [ + "num_queues", + "queue_size", + "socket", + "tag" + ], + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "socket": { + "type": "string" + }, + "num_queues": { + "type": "integer", + "default": 1 + }, + "queue_size": { + "type": "integer", + "default": 1024 + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + } + } + }, + "GenericVhostUserConfig": { + "required": [ + "queue_sizes", + "socket", + "tag", + "virtio_id" + ], + "type": "object", + "properties": { + "socket": { + "type": "string" + }, + "queue_size": { + "type": "array", + "items": { + "type": "uint16" + } + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "virtio_id": { + "type": "uint32" + } + } + }, + "PmemConfig": { + "required": [ + "file" + ], + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "iommu": { + "type": "boolean", + "default": false + }, + "discard_writes": { + "type": "boolean", + "default": false + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + } + } + }, + "ConsoleMode": { + "type": "string", + "enum": [ + "Off", + "Pty", + "Tty", + "File", + "Socket", + "Null" + ] + }, + "ConsoleConfig": { + "required": [ + "mode" + ], + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "socket": { + "type": "string" + }, + "mode": { + "$ref": "#/components/schemas/ConsoleMode" + }, + "iommu": { + "type": "boolean", + "default": false + } + } + }, + "DebugConsoleConfig": { + "required": [ + "mode" + ], + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "mode": { + "$ref": "#/components/schemas/ConsoleMode" + }, + "iobase": { + "type": "integer" + } + } + }, + "DeviceConfig": { + "required": [ + "path" + ], + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "iommu": { + "type": "boolean", + "default": false + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + }, + "x_nv_gpudirect_clique": { + "type": "integer", + "format": "int8" + } + } + }, + "TpmConfig": { + "required": [ + "socket" + ], + "type": "object", + "properties": { + "socket": { + "type": "string" + } + } + }, + "VdpaConfig": { + "required": [ + "path", + "num_queues" + ], + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "num_queues": { + "type": "integer", + "default": 1 + }, + "iommu": { + "type": "boolean", + "default": false + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + } + } + }, + "VsockConfig": { + "required": [ + "cid", + "socket" + ], + "type": "object", + "properties": { + "cid": { + "type": "integer", + "format": "int64", + "minimum": 3, + "description": "Guest Vsock CID" + }, + "socket": { + "type": "string", + "description": "Path to UNIX domain socket, used to proxy vsock connections." + }, + "iommu": { + "type": "boolean", + "default": false + }, + "pci_segment": { + "type": "integer", + "format": "int16" + }, + "id": { + "type": "string" + } + } + }, + "NumaDistance": { + "required": [ + "destination", + "distance" + ], + "type": "object", + "properties": { + "destination": { + "type": "integer", + "format": "int32" + }, + "distance": { + "type": "integer", + "format": "int32" + } + } + }, + "NumaConfig": { + "required": [ + "guest_numa_id" + ], + "type": "object", + "properties": { + "guest_numa_id": { + "type": "integer", + "format": "int32" + }, + "cpus": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "distances": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NumaDistance" + } + }, + "memory_zones": { + "type": "array", + "items": { + "type": "string" + } + }, + "pci_segments": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "device_id": { + "type": "string" + } + } + }, + "VmResize": { + "type": "object", + "properties": { + "desired_vcpus": { + "minimum": 1, + "type": "integer" + }, + "desired_ram": { + "description": "desired memory ram in bytes", + "type": "integer", + "format": "int64" + }, + "desired_balloon": { + "description": "desired balloon size in bytes", + "type": "integer", + "format": "int64" + } + } + }, + "VmResizeDisk": { + "type": "object", + "properties": { + "id": { + "description": "disk identifier", + "type": "string" + }, + "desired_size": { + "description": "desired disk size in bytes", + "type": "integer", + "format": "int64" + } + } + }, + "VmResizeZone": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "desired_ram": { + "description": "desired memory zone size in bytes", + "type": "integer", + "format": "int64" + } + } + }, + "VmRemoveDevice": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "VmSnapshotConfig": { + "type": "object", + "properties": { + "destination_url": { + "type": "string" + } + } + }, + "VmCoredumpData": { + "type": "object", + "properties": { + "destination_url": { + "type": "string" + } + } + }, + "MemoryRestoreMode": { + "type": "string", + "enum": [ + "Copy", + "OnDemand" + ], + "default": "Copy" + }, + "RestoreConfig": { + "required": [ + "source_url" + ], + "type": "object", + "properties": { + "source_url": { + "type": "string" + }, + "prefault": { + "type": "boolean" + }, + "memory_restore_mode": { + "$ref": "#/components/schemas/MemoryRestoreMode" + }, + "resume": { + "type": "boolean" + } + } + }, + "ReceiveMigrationData": { + "required": [ + "receiver_url" + ], + "type": "object", + "properties": { + "receiver_url": { + "type": "string" + } + } + }, + "TimeoutStrategy": { + "type": "string", + "enum": [ + "Cancel", + "Ignore" + ], + "default": "Cancel", + "description": "The strategy to apply when the migration timeout is reached. Cancel will abort the migration and keep the VM running on the source. Ignore will proceed with the migration regardless of the downtime requirement.\n" + }, + "SendMigrationData": { + "required": [ + "destination_url" + ], + "type": "object", + "properties": { + "destination_url": { + "type": "string" + }, + "local": { + "type": "boolean" + }, + "downtime_ms": { + "type": "integer", + "format": "int64", + "minimum": 1, + "default": 300, + "description": "The maximum downtime the migration aims for, in milliseconds. Defaults to 300ms.\n" + }, + "timeout_s": { + "type": "integer", + "format": "int64", + "minimum": 1, + "default": 3600, + "description": "The timeout for the migration (maximum total duration), in seconds. Defaults to 3600s (one hour).\n" + }, + "timeout_strategy": { + "$ref": "#/components/schemas/TimeoutStrategy" + }, + "connections": { + "type": "integer", + "format": "int64", + "default": 1, + "minimum": 1, + "maximum": 128, + "description": "The number of parallel TCP connections to use for migration. Must be between 1 and 128. Multiple connections are not supported with local UNIX-socket migration.\n" + } + } + }, + "VmAddUserDevice": { + "required": [ + "socket" + ], + "type": "object", + "properties": { + "socket": { + "type": "string" + } + } + }, + "LandlockConfig": { + "required": [ + "path", + "access" + ], + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "access": { + "type": "string" + } + } + } + } + }, + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 92dcbfa..5318bec 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -56,7 +56,7 @@ impl ConfigTransform for TransformChain { impl Default for TransformChain { fn default() -> Self { Self::new() - .add(storage::StorageChain::default()) + .add(storage::StorageDriverTransformer::default()) .add(ConsoleTransform) .add(PathVerify) } diff --git a/odorobo-agent/src/state/transform/storage/file.rs b/odorobo-agent/src/state/transform/storage/file.rs index f233be9..a9526de 100644 --- a/odorobo-agent/src/state/transform/storage/file.rs +++ b/odorobo-agent/src/state/transform/storage/file.rs @@ -3,7 +3,7 @@ //! as if we're simply just stripping the prefix //! e.g. `file:///path/to/disk.img` -> `/path/to/disk.img` -use super::StorageBackend; +use super::StorageDriver; use async_trait::async_trait; use stable_eyre::{Result, eyre::eyre}; use std::path::PathBuf; @@ -27,7 +27,7 @@ impl TryFrom<&Url> for FileTarget { pub struct FileStorage; #[async_trait] -impl StorageBackend for FileStorage { +impl StorageDriver for FileStorage { fn scheme(&self) -> &'static str { "file" } diff --git a/odorobo-agent/src/state/transform/storage/iscsi.rs b/odorobo-agent/src/state/transform/storage/iscsi.rs index 50f2781..7324eb9 100644 --- a/odorobo-agent/src/state/transform/storage/iscsi.rs +++ b/odorobo-agent/src/state/transform/storage/iscsi.rs @@ -1,7 +1,7 @@ //! iSCSI initiator transformer for storage backend //! resolves iscsi:// URIs by logging into the iSCSI target and returning the local //! block device path for the specified target and LUN, e.g. /dev/disk/by-path/ip-* -use super::StorageBackend; +use super::StorageDriver; use async_trait::async_trait; use serde::Deserialize; use stable_eyre::{Result, eyre::eyre}; @@ -76,26 +76,26 @@ impl From<&Url> for ISCSITarget { } } -fn list_iscsi_devices() -> Result> { - // simply just list /dev/disk/by-path/ip-* for now - - let mut devices = Vec::new(); - let by_path = Path::new("/dev/disk/by-path"); - if by_path.exists() { - for entry in by_path.read_dir()? { - let entry = entry?; - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - if file_name_str.starts_with("ip-") { - let device_path = entry.path(); - if device_path.exists() { - devices.push(device_path); - } - } - } - } - Ok(devices) -} +// fn list_iscsi_devices() -> Result> { +// // simply just list /dev/disk/by-path/ip-* for now + +// let mut devices = Vec::new(); +// let by_path = Path::new("/dev/disk/by-path"); +// if by_path.exists() { +// for entry in by_path.read_dir()? { +// let entry = entry?; +// let file_name = entry.file_name(); +// let file_name_str = file_name.to_string_lossy(); +// if file_name_str.starts_with("ip-") { +// let device_path = entry.path(); +// if device_path.exists() { +// devices.push(device_path); +// } +// } +// } +// } +// Ok(devices) +// } // /dev/disk/by-path/ip-127.0.0.1:3260-iscsi-iqn.2026-03.com.fyrastack:test-lun-0 @@ -129,7 +129,7 @@ impl TryFrom for ISCSITarget { pub struct ISCSIStorage; #[async_trait] -impl StorageBackend for ISCSIStorage { +impl StorageDriver for ISCSIStorage { fn scheme(&self) -> &'static str { "iscsi" } diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo-agent/src/state/transform/storage/mod.rs index 3cdf9ca..9b89583 100644 --- a/odorobo-agent/src/state/transform/storage/mod.rs +++ b/odorobo-agent/src/state/transform/storage/mod.rs @@ -29,7 +29,7 @@ mod rbd; // using async_trait here because even with Rust 1.75, dyn in async traits do not work due to // vtable issues #[async_trait] -pub trait StorageBackend: Send + Sync { +pub trait StorageDriver: Send + Sync { /// The URI scheme this backend handles, e.g. `"rbd"`, `"file"`. Used for dispatch in `StorageChain`. fn scheme(&self) -> &'static str; @@ -44,21 +44,21 @@ pub trait StorageBackend: Send + Sync { /// whose scheme matches the URI scheme. /// /// Disk paths that are not URIs or whose scheme has no registered backend are left unchanged. -pub struct StorageChain { - backends: Vec>, +pub struct StorageDriverTransformer { + backends: Vec>, } -impl StorageChain { +impl StorageDriverTransformer { pub fn new() -> Self { Self { backends: vec![] } } - pub fn add(mut self, backend: B) -> Self { + pub fn add(mut self, backend: B) -> Self { self.backends.push(Box::new(backend)); self } - fn find_backend(&self, uri: &Url) -> Option<&dyn StorageBackend> { + fn find_backend(&self, uri: &Url) -> Option<&dyn StorageDriver> { self.backends .iter() .find(|b| b.scheme() == uri.scheme()) @@ -89,7 +89,7 @@ impl StorageChain { } } -impl Default for StorageChain { +impl Default for StorageDriverTransformer { fn default() -> Self { Self::new() .add(file::FileStorage) @@ -98,7 +98,7 @@ impl Default for StorageChain { } } -impl ConfigTransform for StorageChain { +impl ConfigTransform for StorageDriverTransformer { fn teardown(&self, _vmid: &str, config: &mut VmConfig) -> Result<()> { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(self.release_config(config)) diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo-agent/src/state/transform/storage/rbd.rs index 8653721..07db5d4 100644 --- a/odorobo-agent/src/state/transform/storage/rbd.rs +++ b/odorobo-agent/src/state/transform/storage/rbd.rs @@ -1,4 +1,4 @@ -use super::StorageBackend; +use super::StorageDriver; use async_trait::async_trait; use stable_eyre::{Result, eyre::eyre}; use std::path::PathBuf; @@ -157,7 +157,7 @@ impl TryFrom<&Url> for RbdImage { pub struct RbdStorage; #[async_trait] -impl StorageBackend for RbdStorage { +impl StorageDriver for RbdStorage { fn scheme(&self) -> &'static str { "rbd" } From 5bf1744131226c671c73df0695ba576b9a2d99d8 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 13:27:23 +0700 Subject: [PATCH 096/189] update new CH API client --- Cargo.lock | 5 ++--- Cargo.toml | 2 +- odorobo-agent/src/state/instance.rs | 2 +- odorobo-agent/src/state/transform/console.rs | 6 +++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5eaf34..a4e8c9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,9 +810,8 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cloud-hypervisor-client" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a135a9339a5ad2775b8843dfad19cd5057e9d8092a3c09b523c5030fcc1348ee" +version = "0.4.0" +source = "git+https://github.com/FyraStack/cloud-hypervisor-client.git#60ef245a60e8ba393f623cf0d30c0c298f0127ff" dependencies = [ "base64 0.7.0", "futures", diff --git a/Cargo.toml b/Cargo.toml index 5364146..094b75a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", "json", ] } -cloud-hypervisor-client = "0.3" +cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } serde_json = "1.0.149" serde = "1.0" axum = { version = "0.8.8", features = ["ws"] } diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 8ea869c..0896750 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -396,7 +396,7 @@ impl VMInstance { trace!(vm_id = self.vm_id(), state = ?info.state, "Checking VM state before destroy"); if matches!( info.state, - models::vm_info::State::Running | models::vm_info::State::Paused + models::VmState::Running | models::VmState::Paused ) { info!(vm_id = self.vm_id(), "Shutting down VM before destroy"); self.shutdown().await?; diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo-agent/src/state/transform/console.rs index a62546d..b76711a 100644 --- a/odorobo-agent/src/state/transform/console.rs +++ b/odorobo-agent/src/state/transform/console.rs @@ -15,7 +15,7 @@ impl ConfigTransform for ConsoleTransform { let runtime_path = VMInstance::runtime_dir_for(vmid); trace!("Applying ConsoleTransform"); config.console = Some(ConsoleConfig { - mode: cloud_hypervisor_client::models::console_config::Mode::Off, + mode: cloud_hypervisor_client::models::ConsoleMode::Off, ..Default::default() }); // note: console passthrough is kinda janky and breaks live migration, needs a way to fix this @@ -25,9 +25,9 @@ impl ConfigTransform for ConsoleTransform { // // consider some virtual GPU device, but CH doesn't have QXL or virtio-gpu so idk config.serial = Some(ConsoleConfig { - mode: cloud_hypervisor_client::models::console_config::Mode::Pty, + mode: cloud_hypervisor_client::models::ConsoleMode::Socket, // file: Some(format!("{}/serial", runtime_path.display())), - // socket: Some(format!("{}/console.sock", runtime_path.display())), + socket: Some(format!("{}/console.sock", runtime_path.display())), ..Default::default() }); From 7f2a54055543c3ee067aecb1bf7c340664710f15 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 19:34:54 +0700 Subject: [PATCH 097/189] use full semver --- odorobo-agent/src/state/instance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 0896750..989b8b6 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -98,7 +98,7 @@ impl VMInstance { /// Get a VM instance by its ID through the filesystem database /// /// Not reliable as of 0.2 - #[deprecated(since = "0.2")] + #[deprecated(since = "0.2.0")] pub fn get(vmid: &str) -> Option { Self::list().ok()?.into_iter().find(|i| i.id == vmid) } @@ -562,7 +562,7 @@ impl VMInstance { /// List running VM instances. /// /// Scans runtime root for directories with valid sockets. - #[deprecated(since = "0.2")] + #[deprecated(since = "0.2.0")] pub fn list() -> Result> { let root = Self::runtime_root(); fs::create_dir_all(&root)?; From 1bcd82578d6bbf79eb52c41ea96646efeb197e0c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 19:43:10 +0700 Subject: [PATCH 098/189] clean up docs for refactor --- README.md | 67 ++++++------------------------------------------------- 1 file changed, 7 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d3628ce..0bd450a 100644 --- a/README.md +++ b/README.md @@ -27,25 +27,20 @@ Odorobo Agent is meant to be run as a system agent on each bare-metal node (or a Build the Agent binary with `cargo build --release` and run it on the host machine. The Agent will listen for commands from the Gateway to create, manage, and delete VMs. -Install the systemd integration first: -```bash -# Install unit hook scripts -sudo just install_script -# finally, install the unit -sudo just install_unit -``` - -Then build and run the agent: ```bash # Build the Agent cargo build --release # Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus -# preferrably as the same user as Cloud Hypervisor, see `systemd/odorobo-ch@.service` -# for the CH service template) sudo ./target/release/odorobo-agent ``` -For debugging and/or small-scale single-node usage, the CLI is available to interact directly with the agent. +You will now be required to run the manager process: +``` +# Build the Manager +cargo build --release -p odorobo-manager +# Run the Manager +./target/release/odorobo-manager +``` Install the CLI helper @@ -67,12 +62,6 @@ Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/clo odoroboctl create my-vm --boot ./my-vm.json ``` -Now the VM should be running. You can connect to the VM's serial console via the agent's WebSocket proxy: - -```bash -websocat --binary ws://127.0.0.1:8890/vms/my-vm/console -``` - To connect directly on the host, look up the PTY path from the VM info: ```bash @@ -85,45 +74,3 @@ See [docs/console.md](docs/console.md) for WebSocket console usage and integrati For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. - -## Live Migration - -To start a live migration, you will first need to spawn a VM on the destination node with `odoroboctl spawn`, then call the `migrate` command on the source node with the destination node's address: - -```bash -odoroboctl spawn my-vm-dest -``` - -Now, on the same destination VM, start accepting migrations: - -```bash -odoroboctl migrate-receive my-vm-dest -``` - -You will now receive a response with the listening address and port for the destination machine. Use this information to start the migration from the source node. - -```json -{"listening_address":"tcp:0.0.0.0:49152"} -``` - -Replace `0.0.0.0` with the actual IP address of the destination node, and use the provided port (e.g. `49152`) - -Finally, start migration from the source node: - -```bash -odoroboctl migrate-send my-vm-source tcp::49152 -``` - -This will start the live migration process. The source VM will continue running until the final switchover phase, at which point it will be paused, the remaining state will be transferred to the destination, and then the destination VM will be resumed. - -You will have to manually manage networking and storage for the VM during migration, as Odorobo does not currently have any built-in network or storage management features. Migrations may fail if the networking configuration is unmigratable. - -This part is currently out-of-scope for Odorobo, as the orchestrator should be responsible for coordinating with the network and storage layers to ensure that the VM's resources are available on the destination node before starting migration. - -Odorobo however has support for custom pre and post-migration hooks, which can be used to implement custom logic before and after migration, such as preparing the destination node's network and storage configuration, or cleaning up the source node after migration. See `odorobo-agent/src/state/provisioning/hooks` for more details on how to implement and configure lifecycle hooks. - -## Security notes - -Currently, the `odorobo-ch@.service` unit is configured to be sandboxed and confined to a list of read-writable paths that are necessary for operation, and by default only has access to `/var/lib/odorobo` and `/dev` for runtime data. - -To allow Cloud Hypervisor to access the disk, you will have to either move your disk images into `/var/lib/odorobo` or add additional read access to the paths where your disk images are stored. From 14e43f25d76e993d229413d020e9c011e5647d33 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 14 Apr 2026 19:54:52 +0700 Subject: [PATCH 099/189] cargo clippy --fix --- odorobo-agent/src/actor/mod.rs | 6 +++--- odorobo-agent/src/api/mod.rs | 3 --- odorobo-agent/src/api/vm.rs | 10 ---------- odorobo-agent/src/main.rs | 2 -- odorobo-agent/src/state/instance.rs | 4 +--- odorobo-agent/src/state/mod.rs | 3 +-- .../src/state/provisioning/actor/mod.rs | 4 ++-- .../src/state/transform/path_verify.rs | 15 ++++++--------- .../src/state/transform/storage/iscsi.rs | 12 ++++++------ .../src/state/transform/storage/mod.rs | 8 ++++---- odorobo-manager/src/actors/http_actor.rs | 8 ++------ .../src/actors/ip_management_actor.rs | 1 - odorobo-manager/src/actors/scheduler_actor.rs | 12 ++++++------ .../actors/serial_terminal_websocket_actor.rs | 1 - odorobo-manager/src/actors/storage_actor.rs | 3 --- odorobo-manager/src/api/nodes.rs | 6 +++--- odorobo-manager/src/api/vms.rs | 10 +++++----- odorobo-manager/src/api/volumes.rs | 18 +++++++++--------- odorobo-shared/src/error.rs | 1 - odorobo-shared/src/lib.rs | 6 +----- odorobo-shared/src/utils.rs | 2 +- 21 files changed, 50 insertions(+), 85 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 9330ab7..e8e5d5f 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -84,7 +84,7 @@ impl Actor for AgentActor { type Args = (); type Error = Report; - async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { + async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { // TODO: ask infra team where they want this on the box let file = fs::File::open("config.json").expect("file should open read only"); let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); @@ -105,7 +105,7 @@ impl Actor for AgentActor { // async fn on_panic( &mut self, - actor_ref: WeakActorRef, + _actor_ref: WeakActorRef, err: PanicError, ) -> Result> { error!("Agent panicked: {:?}", err); @@ -289,7 +289,7 @@ impl Message for AgentActor { async fn handle( &mut self, - msg: PanicAgent, + _msg: PanicAgent, _ctx: &mut Context, ) -> Self::Reply { tracing::info!("panicking"); diff --git a/odorobo-agent/src/api/mod.rs b/odorobo-agent/src/api/mod.rs index 7549663..3b9576c 100644 --- a/odorobo-agent/src/api/mod.rs +++ b/odorobo-agent/src/api/mod.rs @@ -2,9 +2,6 @@ mod ch; mod console; mod error; -use axum::{Json, extract::State}; -use serde::Serialize; -use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; mod vm; // pub fn router(port: u16) -> axum::Router<()> { diff --git a/odorobo-agent/src/api/vm.rs b/odorobo-agent/src/api/vm.rs index 96124cb..85b9a6d 100644 --- a/odorobo-agent/src/api/vm.rs +++ b/odorobo-agent/src/api/vm.rs @@ -1,14 +1,4 @@ -use axum::{ - Json, - extract::{Path, Query}, -}; -use cloud_hypervisor_client::models::{self, VmInfo, VmmPingResponse}; -use serde::{Deserialize, Serialize}; -use stable_eyre::Result; -use tracing::{error, trace}; -use super::error::ApiError; -use crate::state::VMInstance; // pub fn router() -> axum::Router<()> { // axum::Router::new() diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 1835175..467f937 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -5,8 +5,6 @@ mod util; use kameo::actor::Spawn; use odorobo_shared::connect_to_swarm; use stable_eyre::Result; -use tracing::level_filters::LevelFilter; -use tracing_subscriber::EnvFilter; // use odorobo_shared:: use crate::actor::AgentActor; diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 989b8b6..120421f 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -3,9 +3,7 @@ use cloud_hypervisor_client::{ apis::{DefaultApi, Error as ChClientError}, models::{self, VmConfig, VmInfo, VmmPingResponse}, }; -use hyper::Method; use hyper::{Request, Response, body::Bytes}; -use serde_json::Value; use stable_eyre::{ Result, eyre::{Context, eyre}, @@ -24,7 +22,7 @@ use crate::state::{ transform::{ConfigTransform, TransformChain}, }; -use super::api::{call, call_request}; +use super::api::call_request; pub const CONFIG_FILE_NAME: &str = "config.json"; const SOCKET_FILE_NAME: &str = "ch.sock"; diff --git a/odorobo-agent/src/state/mod.rs b/odorobo-agent/src/state/mod.rs index 7f09e7e..00fa350 100644 --- a/odorobo-agent/src/state/mod.rs +++ b/odorobo-agent/src/state/mod.rs @@ -9,6 +9,5 @@ pub mod instance; pub mod provisioning; pub mod transform; -pub use api::{call, call_request}; -pub use instance::{CONFIG_FILE_NAME, ChApiError, ConsoleStream, VMInstance, VMS_DIR_NAME}; +pub use instance::{ChApiError, ConsoleStream, VMInstance}; // pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 0ae755c..9c63f0d 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -43,7 +43,7 @@ impl Actor for VMActor { type Error = Report; #[tracing::instrument(skip_all)] - async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { + async fn on_start((vmid, vm_config): Self::Args, _actor_ref: ActorRef) -> Result { // let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); // // no transform chain @@ -74,7 +74,7 @@ impl Actor for VMActor { async fn on_stop( &mut self, - actor_ref: WeakActorRef, + _actor_ref: WeakActorRef, reason: ActorStopReason, ) -> std::result::Result<(), Self::Error> { match reason { diff --git a/odorobo-agent/src/state/transform/path_verify.rs b/odorobo-agent/src/state/transform/path_verify.rs index 34aa96b..ffe907b 100644 --- a/odorobo-agent/src/state/transform/path_verify.rs +++ b/odorobo-agent/src/state/transform/path_verify.rs @@ -18,21 +18,18 @@ impl ConfigTransform for PathVerify { trace!("Verifying paths"); let config = config.clone(); // payload path verification - if let Some(kernel_path) = config.payload.kernel { - if !Path::new(&kernel_path).is_absolute() { + if let Some(kernel_path) = config.payload.kernel + && !Path::new(&kernel_path).is_absolute() { return Err(eyre!("Kernel must be an absolute path")); } - } - if let Some(initramfs_path) = config.payload.initramfs { - if !Path::new(&initramfs_path).is_absolute() { + if let Some(initramfs_path) = config.payload.initramfs + && !Path::new(&initramfs_path).is_absolute() { return Err(eyre!("initramfs must be an absolute path")); } - } - if let Some(firmware_path) = config.payload.firmware { - if !Path::new(&firmware_path).is_absolute() { + if let Some(firmware_path) = config.payload.firmware + && !Path::new(&firmware_path).is_absolute() { return Err(eyre!("firmware must be an absolute path")); } - } // storage path verification if let Some(disk_configs) = config.disks { diff --git a/odorobo-agent/src/state/transform/storage/iscsi.rs b/odorobo-agent/src/state/transform/storage/iscsi.rs index 7324eb9..554a20e 100644 --- a/odorobo-agent/src/state/transform/storage/iscsi.rs +++ b/odorobo-agent/src/state/transform/storage/iscsi.rs @@ -5,9 +5,9 @@ use super::StorageDriver; use async_trait::async_trait; use serde::Deserialize; use stable_eyre::{Result, eyre::eyre}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokio::process::Command; -use tracing::{info, trace}; +use tracing::info; use url::Url; /// Struct representation of an iSCSI target, @@ -53,9 +53,9 @@ impl ISCSITarget { } } -impl Into for ISCSITarget { - fn into(self) -> PathBuf { - self.to_device_path() +impl From for PathBuf { + fn from(val: ISCSITarget) -> Self { + val.to_device_path() } } @@ -65,7 +65,7 @@ impl From<&Url> for ISCSITarget { let port = uri.port().unwrap_or(3260); let host = format!("{}:{}", host_ip, port); let path_segments: Vec<&str> = uri.path_segments().map(|c| c.collect()).unwrap_or_default(); - let iqn = path_segments.get(0).unwrap_or(&"").to_string(); + let iqn = path_segments.first().unwrap_or(&"").to_string(); let lun_str = path_segments.get(1).unwrap_or(&""); let lun = lun_str .strip_prefix("lun") diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo-agent/src/state/transform/storage/mod.rs index 9b89583..1021465 100644 --- a/odorobo-agent/src/state/transform/storage/mod.rs +++ b/odorobo-agent/src/state/transform/storage/mod.rs @@ -53,7 +53,7 @@ impl StorageDriverTransformer { Self { backends: vec![] } } - pub fn add(mut self, backend: B) -> Self { + pub fn with_backend(mut self, backend: B) -> Self { self.backends.push(Box::new(backend)); self } @@ -92,9 +92,9 @@ impl StorageDriverTransformer { impl Default for StorageDriverTransformer { fn default() -> Self { Self::new() - .add(file::FileStorage) - .add(rbd::RbdStorage) - .add(iscsi::ISCSIStorage) + .with_backend(file::FileStorage) + .with_backend(rbd::RbdStorage) + .with_backend(iscsi::ISCSIStorage) } } diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo-manager/src/actors/http_actor.rs index 6659f07..61b88fb 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo-manager/src/actors/http_actor.rs @@ -1,13 +1,9 @@ use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; -use odorobo_agent::state::provisioning::actor::VMActor; -use odorobo_shared::{ - messages::vm::{ +use odorobo_shared::messages::vm::{ AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, ShutdownVMReply, - }, - utils::vm_actor_id, -}; + }; use stable_eyre::{Report, Result}; use crate::api::types::CreateVMRequest; diff --git a/odorobo-manager/src/actors/ip_management_actor.rs b/odorobo-manager/src/actors/ip_management_actor.rs index 4ac4627..9796b38 100644 --- a/odorobo-manager/src/actors/ip_management_actor.rs +++ b/odorobo-manager/src/actors/ip_management_actor.rs @@ -1,6 +1,5 @@ use kameo::prelude::*; use stable_eyre::{Report, Result}; -use tracing::{error, info, warn}; // idk if we ever agreed upon an OUI for fyra, but im reserving `FYR` for this // -cappy diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 64c7795..68f67c3 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -28,7 +28,7 @@ impl SchedulerActor { continue; }; - let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); + let agent_actor_peer_id = *agent_actor.id().peer_id().unwrap(); info!("Using agent actor peer id: {agent_actor_peer_id}"); // remotely link actor, on link death it will be automatically unlinked @@ -58,7 +58,7 @@ impl Actor for SchedulerActor { type Error = Report; async fn on_start(_state: Self::Args, actor_ref: ActorRef) -> Result { - let peer_id = actor_ref.id().peer_id().unwrap().clone(); + let peer_id = *actor_ref.id().peer_id().unwrap(); info!("Actor started! Scheduler peer id: {peer_id}"); @@ -121,7 +121,7 @@ impl Message for SchedulerActor { async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { let actor_ref = ctx.actor_ref(); - let first_agent = self.ensure_agent(&actor_ref).await?; + let first_agent = self.ensure_agent(actor_ref).await?; match first_agent.ask(&msg).await { Ok(reply) => Ok(reply), Err(first_err) => { @@ -130,7 +130,7 @@ impl Message for SchedulerActor { ); self.agent_actor = None; - let retry_agent = self.ensure_agent(&actor_ref).await?; + let retry_agent = self.ensure_agent(actor_ref).await?; retry_agent.ask(&msg).await.map_err(|retry_err| { eyre!( "failed to forward CreateVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" @@ -204,7 +204,7 @@ impl Message for SchedulerActor { let actor_ref = ctx.actor_ref(); - let first_agent = self.ensure_agent(&actor_ref).await?; + let first_agent = self.ensure_agent(actor_ref).await?; match first_agent.ask(&msg).await { Ok(reply) => Ok(reply), Err(first_err) => { @@ -213,7 +213,7 @@ impl Message for SchedulerActor { ); self.agent_actor = None; - let retry_agent = self.ensure_agent(&actor_ref).await?; + let retry_agent = self.ensure_agent(actor_ref).await?; retry_agent.ask(&msg).await.map_err(|retry_err| { eyre!( "failed to forward AgentListVMs to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" diff --git a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs index f8b95e5..a74cdc4 100644 --- a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs +++ b/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs @@ -1,6 +1,5 @@ use kameo::prelude::*; use stable_eyre::{Report, Result}; -use tracing::{error, info, warn}; /// HTTP REST API service #[derive(RemoteActor)] diff --git a/odorobo-manager/src/actors/storage_actor.rs b/odorobo-manager/src/actors/storage_actor.rs index d22abb6..83c3317 100644 --- a/odorobo-manager/src/actors/storage_actor.rs +++ b/odorobo-manager/src/actors/storage_actor.rs @@ -1,9 +1,6 @@ use kameo::prelude::*; -use tracing::{error, info, warn}; use stable_eyre::{Report, Result}; - -/// #[derive(RemoteActor)] pub struct SerialTerminalWebsocketActor; diff --git a/odorobo-manager/src/api/nodes.rs b/odorobo-manager/src/api/nodes.rs index 19822c5..81a6c8c 100644 --- a/odorobo-manager/src/api/nodes.rs +++ b/odorobo-manager/src/api/nodes.rs @@ -16,15 +16,15 @@ pub fn router() -> ApiRouter> { .api_route("/{nodeid}", get(node_info)) } /// Drain a node of all VMs, migrating them away or shutting them down as needed. This is used for maintenance mode. -async fn drain(State(state): State>) -> impl IntoApiResponse { +async fn drain(State(_state): State>) -> impl IntoApiResponse { // stub Json("Draining...".to_string()) } /// Get detailed information about a specific node, including its current VMs and resource usage. async fn node_info( - State(state): State>, - Path(node_id): Path, + State(_state): State>, + Path(_node_id): Path, ) -> impl IntoApiResponse { // stub, Json(Node::default()) diff --git a/odorobo-manager/src/api/vms.rs b/odorobo-manager/src/api/vms.rs index 52fb585..cb2571a 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo-manager/src/api/vms.rs @@ -39,8 +39,8 @@ async fn list_vms(State(state): State>) -> impl IntoApiRespo /// Get detailed information about a specific VM async fn vm_info( - State(state): State>, - Path(VmId(vmid)): Path, + State(_state): State>, + Path(VmId(_vmid)): Path, ) -> impl IntoApiResponse { // stub, Json(VMInfo::default()) @@ -106,9 +106,9 @@ async fn shutdown_vm( /// /// todo: make new schema for update request that allows partial updates async fn update_vm( - State(state): State>, - Path(VmId(vmid)): Path, - Json(request): Json, + State(_state): State>, + Path(VmId(_vmid)): Path, + Json(_request): Json, ) -> impl IntoApiResponse { // stub diff --git a/odorobo-manager/src/api/volumes.rs b/odorobo-manager/src/api/volumes.rs index 1209ea6..41f6e47 100644 --- a/odorobo-manager/src/api/volumes.rs +++ b/odorobo-manager/src/api/volumes.rs @@ -1,7 +1,7 @@ //! Volume management API handlers. use crate::{ actors::http_actor::HTTPActor, - api::types::{CreateVolumeRequest, Volume, VolumeId, VolumeInfo}, + api::types::{CreateVolumeRequest, VolumeId, VolumeInfo}, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -22,32 +22,32 @@ pub fn router() -> ApiRouter> { } /// Get detailed information about a specific volume -async fn volume_info(Path(VolumeId(volid)): Path) -> impl IntoApiResponse { +async fn volume_info(Path(VolumeId(_volid)): Path) -> impl IntoApiResponse { // stub, Json(VolumeInfo::default()) } /// Create a new volume with the specified parameters async fn create_volume( - State(state): State>, - Json(request): Json, + State(_state): State>, + Json(_request): Json, ) -> impl IntoApiResponse { // stub Json(VolumeInfo::default()) } /// Delete an existing volume by ID async fn delete_volume( - State(state): State>, - Path(VolumeId(volid)): Path, + State(_state): State>, + Path(VolumeId(_volid)): Path, ) -> impl IntoApiResponse { // stub } /// Resize an existing volume to a new size async fn resize_volume( - State(state): State>, - Path(VolumeId(volid)): Path, - Json(request): Json, + State(_state): State>, + Path(VolumeId(_volid)): Path, + Json(_request): Json, ) -> impl IntoApiResponse { // stub Json(VolumeInfo::default()) diff --git a/odorobo-shared/src/error.rs b/odorobo-shared/src/error.rs index 40c75fe..6653ef9 100644 --- a/odorobo-shared/src/error.rs +++ b/odorobo-shared/src/error.rs @@ -1,4 +1,3 @@ -use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index 4bf7051..d5d1b54 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -2,14 +2,10 @@ pub mod error; pub mod messages; pub mod utils; use kameo::prelude::*; -use libp2p::bytes::BufMut; use libp2p::futures::StreamExt; -use libp2p::kad::Record; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; -use libp2p::{PeerId, kad, mdns, noise, tcp, yamux}; +use libp2p::{PeerId, mdns, noise, tcp, yamux}; use stable_eyre::Result; -use std::cell::RefCell; -use std::time::Duration; use tracing::{debug, info, trace, warn}; #[derive(NetworkBehaviour)] diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs index ee710e7..c02ffc3 100644 --- a/odorobo-shared/src/utils.rs +++ b/odorobo-shared/src/utils.rs @@ -1,6 +1,6 @@ use stable_eyre::Result; // use tracing:: -use tracing_subscriber::{EnvFilter, filter::LevelFilter, fmt, prelude::*}; +use tracing_subscriber::{EnvFilter, filter::LevelFilter, prelude::*}; use ulid::Ulid; pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { From 05e039a55891cc779b61c91766681442ab0f95aa Mon Sep 17 00:00:00 2001 From: Pornpipat Popum Date: Tue, 14 Apr 2026 20:28:15 +0700 Subject: [PATCH 100/189] Potential fix for code scanning alert no. 180: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/build_x86.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 88cde71..21e09bc 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + env: CARGO_TERM_COLOR: always SCCACHE_GHA_ENABLED: "true" From 7ca2e62a0052a7df163f5d23dd2f66607743c1ea Mon Sep 17 00:00:00 2001 From: Pornpipat Popum Date: Tue, 14 Apr 2026 20:35:39 +0700 Subject: [PATCH 101/189] Potential fix for code scanning alert no. 3: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/build_riscv_rva23.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index 70540ab..1183270 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -4,6 +4,10 @@ on: push: pull_request: +permissions: + contents: read + actions: read + env: CARGO_TERM_COLOR: always From 56cfa2f51155025361046afce52df89c06b22286 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Wed, 15 Apr 2026 00:10:30 +0700 Subject: [PATCH 102/189] hooks: pass in VmInfo for post-boot and stop --- odorobo-agent/src/state/instance.rs | 6 +++++- .../src/state/provisioning/hooks/machined.rs | 6 +++--- odorobo-agent/src/state/provisioning/hooks/mod.rs | 14 +++++++------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 120421f..e588c22 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -116,8 +116,12 @@ impl VMInstance { .map_err(ChApiError::from) .wrap_err(eyre!("Failed to boot VM {}", self.vm_id()))?; + // get VM info after boot + + let vm_info_postboot = self.info().await?; + self.hook_manager - .after_boot(self.vm_id(), &vm_config) + .after_boot(self.vm_id(), &vm_info_postboot) .await?; Ok(()) diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo-agent/src/state/provisioning/hooks/machined.rs index bef0966..e4a6bb4 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo-agent/src/state/provisioning/hooks/machined.rs @@ -4,7 +4,7 @@ use crate::state::VMInstance; use crate::state::provisioning::hooks::ProvisioningHook; use crate::util::zbus_system_connection; use async_trait::async_trait; -use cloud_hypervisor_client::models::VmConfig; +use cloud_hypervisor_client::models::VmInfo; use stable_eyre::eyre::Context; use stable_eyre::{Result, eyre::eyre}; use zbus_systemd::machine1::ManagerProxy; @@ -40,7 +40,7 @@ pub struct CHMachineProvisioningHook; #[async_trait] impl ProvisioningHook for CHMachineProvisioningHook { - async fn after_start(&self, vmid: &str, _config: &VmConfig, pid: i32) -> Result<()> { + async fn after_start(&self, vmid: &str, _config: &VmInfo, pid: i32) -> Result<()> { if pid == 0 { tracing::warn!( vmid, @@ -74,7 +74,7 @@ impl ProvisioningHook for CHMachineProvisioningHook { Ok(()) } - async fn before_stop(&self, vmid: &str, _config: &VmConfig) -> Result<()> { + async fn before_stop(&self, vmid: &str, _config: &VmInfo) -> Result<()> { tracing::info!(vmid, "Unregistering machine from systemd-machined"); let res = async { let manager = get_manager_proxy().await?; diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index de224c8..34f793a 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -8,7 +8,7 @@ //! to accomodate for the host environment, while hooks provide ways for the host itself //! to react to provisioning events and perform necessary setup/teardown actions. use async_trait::async_trait; -use cloud_hypervisor_client::models::VmConfig; +use cloud_hypervisor_client::models::{VmConfig, VmInfo}; use stable_eyre::Result; mod machined; @@ -19,10 +19,10 @@ pub trait ProvisioningHook: Send + Sync { async fn before_start(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { Ok(()) } - async fn after_start(&self, _vmid: &str, _config: &VmConfig, _pid: i32) -> Result<()> { + async fn after_start(&self, _vmid: &str, _config: &VmInfo, _pid: i32) -> Result<()> { Ok(()) } - async fn before_stop(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + async fn before_stop(&self, _vmid: &str, _config: &VmInfo) -> Result<()> { Ok(()) } async fn after_stop(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { @@ -31,7 +31,7 @@ pub trait ProvisioningHook: Send + Sync { async fn before_boot(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { Ok(()) } - async fn after_boot(&self, _vmid: &str, _config: &VmConfig) -> Result<()> { + async fn after_boot(&self, _vmid: &str, _config: &VmInfo) -> Result<()> { Ok(()) } } @@ -53,14 +53,14 @@ impl HookManager { Ok(()) } - pub async fn after_start(&self, vmid: &str, config: &VmConfig, pid: i32) -> Result<()> { + pub async fn after_start(&self, vmid: &str, config: &VmInfo, pid: i32) -> Result<()> { for hook in &self.hooks { hook.after_start(vmid, config, pid).await?; } Ok(()) } - pub async fn before_stop(&self, vmid: &str, config: &VmConfig) -> Result<()> { + pub async fn before_stop(&self, vmid: &str, config: &VmInfo) -> Result<()> { for hook in &self.hooks { hook.before_stop(vmid, config).await?; } @@ -81,7 +81,7 @@ impl HookManager { Ok(()) } - pub async fn after_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { + pub async fn after_boot(&self, vmid: &str, config: &VmInfo) -> Result<()> { for hook in &self.hooks { hook.after_boot(vmid, config).await?; } From eb13d0560145edbfaebd8edaa63fa0f639c7466d Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 07:59:27 +0700 Subject: [PATCH 103/189] add internal networking messages --- odorobo-agent/src/lib.rs | 1 + odorobo-agent/src/networking/messages.rs | 60 ++++++++++++++++++++++++ odorobo-agent/src/networking/mod.rs | 1 + 3 files changed, 62 insertions(+) create mode 100644 odorobo-agent/src/networking/messages.rs create mode 100644 odorobo-agent/src/networking/mod.rs diff --git a/odorobo-agent/src/lib.rs b/odorobo-agent/src/lib.rs index 5db2813..4f7f4e7 100644 --- a/odorobo-agent/src/lib.rs +++ b/odorobo-agent/src/lib.rs @@ -1,4 +1,5 @@ pub mod actor; mod api; +pub mod networking; pub mod state; mod util; diff --git a/odorobo-agent/src/networking/messages.rs b/odorobo-agent/src/networking/messages.rs new file mode 100644 index 0000000..a22403d --- /dev/null +++ b/odorobo-agent/src/networking/messages.rs @@ -0,0 +1,60 @@ +use stable_eyre::Result; + +/// Ensures the agent-global networking state is present and configured. +/// +/// This is intended for local actor-to-actor use inside the agent process and +/// should not be exposed as a remote/shared message. +#[derive(Debug, Clone, Default)] +pub struct EnsureHostNetwork; + +/// Attaches a Cloud Hypervisor-created TAP device to the agent-managed network. +/// +/// The TAP device is expected to already exist by the time this message is sent, +/// typically from a post-boot provisioning hook once runtime interface details +/// are known. +#[derive(Debug, Clone)] +pub struct AttachTap { + /// VM identifier for logging and future bookkeeping. + pub vmid: String, + /// Host TAP device name created for this VM by Cloud Hypervisor. + pub tap_name: String, +} + +/// Detaches a TAP device from the agent-managed network. +/// +/// This is optional for now, but defining it up front keeps the local message +/// API stable when stop-time cleanup is added later. +#[derive(Debug, Clone)] +pub struct DetachTap { + /// VM identifier for logging and future bookkeeping. + pub vmid: String, + /// Host TAP device name previously attached for this VM. + pub tap_name: String, +} + +/// Requests a snapshot of the local networking actor state. +#[derive(Debug, Clone, Default)] +pub struct Status; + +/// Local networking status snapshot returned by the networking actor. +#[derive(Debug, Clone, Default)] +pub struct NetworkStatus { + /// Whether host-global networking initialization has completed successfully. + pub initialized: bool, + /// Configured bridge device managed by the actor, if any. + pub bridge: Option, + /// TAP devices currently tracked as attached to the managed bridge. + pub attached_taps: Vec, +} + +/// Bookkeeping entry for an attached TAP device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachedTap { + /// VM identifier associated with the TAP. + pub vmid: String, + /// Host TAP device name. + pub tap_name: String, +} + +/// Convenience alias for local networking actor replies. +pub type NetworkResult = Result; diff --git a/odorobo-agent/src/networking/mod.rs b/odorobo-agent/src/networking/mod.rs new file mode 100644 index 0000000..ba63992 --- /dev/null +++ b/odorobo-agent/src/networking/mod.rs @@ -0,0 +1 @@ +pub mod messages; From 67539a7d8c68b88368b79cd63872536b964eec06 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 08:15:35 +0700 Subject: [PATCH 104/189] schema: replace int types properly --- docs/schemas/cloud-hypervisor.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/schemas/cloud-hypervisor.json b/docs/schemas/cloud-hypervisor.json index b2c7d6e..49f1f68 100644 --- a/docs/schemas/cloud-hypervisor.json +++ b/docs/schemas/cloud-hypervisor.json @@ -1141,7 +1141,7 @@ }, "iommu_address_width": { "type": "integer", - "format": "uint8" + "format": "int8" }, "serial_number": { "type": "string" @@ -1596,7 +1596,7 @@ "queue_size": { "type": "array", "items": { - "type": "uint16" + "type": "int16" } }, "pci_segment": { @@ -1604,7 +1604,7 @@ "format": "int16" }, "virtio_id": { - "type": "uint32" + "type": "int32" } } }, From 5067c9b276f1fac2cd38ad7ca3805a8f3e326836 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 10:49:17 +0700 Subject: [PATCH 105/189] create interfaces and actor --- Cargo.lock | 62 ++- Cargo.toml | 1 + odorobo-agent/Cargo.toml | 6 +- odorobo-agent/src/actor/mod.rs | 97 ++++- odorobo-agent/src/main.rs | 1 + odorobo-agent/src/networking/actor.rs | 366 ++++++++++++++++++ odorobo-agent/src/networking/messages.rs | 11 +- odorobo-agent/src/networking/mod.rs | 1 + odorobo-agent/src/state/instance.rs | 1 + .../src/state/provisioning/hooks/mod.rs | 6 +- .../state/provisioning/hooks/networking.rs | 100 +++++ 11 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 odorobo-agent/src/networking/actor.rs create mode 100644 odorobo-agent/src/state/provisioning/hooks/networking.rs diff --git a/Cargo.lock b/Cargo.lock index a4e8c9f..82b5e1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ dependencies = [ "cfg-if", "http 1.4.0", "indexmap", - "schemars", + "schemars 0.9.0", "serde", "serde_json", "serde_qs", @@ -2069,6 +2069,10 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "schemars 0.8.22", + "serde", +] [[package]] name = "iri-string" @@ -2705,6 +2709,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mnl-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f3c607af12ed53f3cade8ee7adeced5533800abc0b07f5b6bdc7081d1e0d4c" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "moka" version = "0.12.15" @@ -2838,6 +2852,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "nftnl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a530f904e31675dc9d2e3a5c166a844dffc3dad644205fe5abb638f4e9e75d9b" +dependencies = [ + "bitflags", + "log", + "nftnl-sys", + "nix", +] + +[[package]] +name = "nftnl-sys" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffa28b4c81eea6a8a4fd454634d425e1a559250a50a926de4517dd9607fe9a4" +dependencies = [ + "cfg-if", + "libc", + "mnl-sys", + "pkg-config", +] + [[package]] name = "nix" version = "0.30.1" @@ -2963,11 +3001,14 @@ dependencies = [ "hyper-util", "hyperlocal", "if-addrs 0.13.4", + "ipnet", "kameo", "libc", "libp2p", + "nftnl", "odorobo-shared", "random-port", + "rtnetlink", "serde", "serde_json", "stable-eyre", @@ -3000,7 +3041,7 @@ dependencies = [ "odorobo-shared", "optional_struct", "reqwest", - "schemars", + "schemars 0.9.0", "serde", "serde_json", "stable-eyre", @@ -3214,6 +3255,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polling" version = "3.11.0" @@ -3836,6 +3883,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 094b75a..9bff8ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ odoroboctl = { path = "odoroboctl" } odorobo-manager = { path = "odorobo-manager" } dotenvy = "0.15" clap = { version = "4", features = ["derive", "env"] } +ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. # also look in .cargo/config.toml for info on microarchitecture optimizations. diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index e2f8a29..a9ef5bf 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -44,4 +44,8 @@ ahash = { workspace = true } sysinfo = "0.38.4" bytesize = "2.3.1" odorobo-shared = { workspace = true } -ulid = { workspace = true } \ No newline at end of file +ulid = { workspace = true } +ipnet = { workspace = true } +rtnetlink = "0.20.0" +nftnl = { version = "0.9.1", features = ["nftnl-1-1-3"] } + diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index e8e5d5f..07e826d 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -1,6 +1,7 @@ -use crate::state::provisioning::actor::VMActor; +use crate::{networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor}; use ahash::AHashMap; use bytesize::ByteSize; +use ipnet::Ipv4Net; use kameo::prelude::*; use odorobo_shared::{ messages::{Ping, Pong, debug::PanicAgent, vm::*}, @@ -8,8 +9,8 @@ use odorobo_shared::{ }; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; -use std::fs; use std::ops::ControlFlow; +use std::{fs, net::Ipv4Addr}; use sysinfo::System; use tracing::{error, info, trace, warn}; use ulid::Ulid; @@ -22,6 +23,7 @@ pub struct AgentActor { pub memory: ByteSize, pub config: Config, pub vms: AHashMap>, + // pub network_actor: ActorRef, } /// Gets the system hostname @@ -45,6 +47,88 @@ fn default_region() -> String { "Local".into() } +fn default_bridge_name() -> String { + "vmbr0".into() +} + +fn default_subnet() -> Ipv4Net { + "10.0.0.0/24".parse().unwrap() +} + +fn default_gateway() -> Ipv4Addr { + "10.0.0.1".parse().unwrap() +} + +fn default_upstream_iface() -> String { + "eth0".into() +} + +/// DHCP server config +/// +/// config options for dnsmasq +/// +/// this configures what options +// --no-daemon +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DhcpConfig { + pub upstream_iface: String, + pub range: (Ipv4Addr, Ipv4Addr), + pub mask: Ipv4Net, + /// lease time for DHCP clients + /// + /// example: 12h, 6h, 30m + pub lease_time: String, +} + +// TODO: move config into a separate module +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct NetworkConfig { + pub dhcp_config: Option, + pub network_mode: NetworkMode, +} + +/// L3 routing configuration for guests +#[derive(Serialize, Deserialize, Clone)] +pub enum NetworkMode { + /// Private guest bridge with host-side gateway and outbound NAT. + HostonlyNat { + #[serde(default = "default_bridge_name")] + bridge: String, + #[serde(default = "default_subnet")] + subnet: Ipv4Net, + #[serde(default = "default_gateway")] + gateway: Ipv4Addr, + #[serde(default = "default_upstream_iface")] + upstream_iface: String, + }, + /// Flat bridge mode for operator-managed uplinks. + /// + /// The agent should only ensure that the bridge exists, is up, and has the + /// configured host address on it. It should not automatically enslave a + /// physical uplink into the bridge. Operators are expected to attach the + /// upstream interface themselves and handle any host networking migration + /// required for their environment. + /// + /// Per-VM TAP devices can still be attached to this bridge in the same way + /// as NAT mode. + Bridged { + bridge: String, + subnet: Ipv4Net, + gateway: Ipv4Addr, + }, +} + +impl Default for NetworkMode { + fn default() -> Self { + Self::HostonlyNat { + bridge: default_bridge_name(), + subnet: default_subnet(), + gateway: default_gateway(), + upstream_iface: default_upstream_iface(), + } + } +} + // The infra team wants a config file on the box where they can set info specific for the box its on. // TODO: Double check with infra team (katherine) if they want any other config on the box. #[derive(Serialize, Deserialize)] @@ -69,6 +153,8 @@ pub struct Config { /// Arbitrary annotations that can be used #[serde(default)] pub annotations: AHashMap, + #[serde(default)] + pub network: NetworkConfig, } impl AgentActor { @@ -84,11 +170,16 @@ impl Actor for AgentActor { type Args = (); type Error = Report; - async fn on_start(_state: Self::Args, _actor_ref: ActorRef) -> Result { + async fn on_start(_state: Self::Args, actor_ref: ActorRef) -> Result { // TODO: ask infra team where they want this on the box let file = fs::File::open("config.json").expect("file should open read only"); let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); + // spawn networking actor + let network_actor: ActorRef = + NetworkAgentActor::spawn_link(&actor_ref, config.network.clone()).await; + network_actor.register("network_actor").await?; + let sys = System::new_all(); Ok(AgentActor { diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 467f937..11a7591 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -1,5 +1,6 @@ pub mod actor; mod api; +pub mod networking; mod state; mod util; use kameo::actor::Spawn; diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs new file mode 100644 index 0000000..e3da275 --- /dev/null +++ b/odorobo-agent/src/networking/actor.rs @@ -0,0 +1,366 @@ +use crate::actor::{DhcpConfig, NetworkConfig, NetworkMode}; +use crate::networking::messages::{AttachTap, DetachTap}; +use futures_util::{StreamExt, TryStreamExt}; + +use kameo::{message::Context, prelude::*}; +use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; +use stable_eyre::Report; +use stable_eyre::eyre::{Context as EyreContext, eyre}; +use tracing::info; + +pub struct DhcpActor { + pub config: DhcpConfig, +} + +pub struct NetworkConfigCommon { + pub bridge: String, + pub subnet: String, +} + +impl Actor for DhcpActor { + type Args = DhcpConfig; + type Error = Report; + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + Ok(Self { config: args }) + } +} + +async fn ensure_address( + handle: &Handle, + link_index: u32, + link_name: &str, + address: std::net::Ipv4Addr, + prefix_len: u8, +) -> Result<(), Report> { + let mut existing = handle + .address() + .get() + .set_link_index_filter(link_index) + .execute(); + + while let Some(address_msg) = existing.try_next().await? { + if address_msg.header.prefix_len != prefix_len { + continue; + } + + let already_present = address_msg.attributes.iter().any(|attr| match attr { + rtnetlink::packet_route::address::AddressAttribute::Address(ip) + | rtnetlink::packet_route::address::AddressAttribute::Local(ip) => { + matches!(ip, std::net::IpAddr::V4(existing_ip) if *existing_ip == address) + } + _ => false, + }); + + if already_present { + info!( + bridge = link_name, + address = %address, + prefix_len, + "bridge gateway address already present" + ); + return Ok(()); + } + } + + info!( + bridge = link_name, + address = %address, + prefix_len, + "adding gateway address to bridge" + ); + + match handle + .address() + .add(link_index, address.into(), prefix_len) + .execute() + .await + { + Ok(()) => Ok(()), + Err(NetlinkError::NetlinkError(err)) if err.raw_code() == -libc::EEXIST => Ok(()), + Err(err) => Err(err).wrap_err_with(|| { + format!("failed to add address {address}/{prefix_len} to {link_name}") + }), + } +} +#[derive(RemoteActor)] +pub struct NetworkAgentActor { + pub config: NetworkConfig, + common: NetworkConfigCommon, + pub dhcp_actor: Option>, + // netlink_handle: + netlink_thread: tokio::task::JoinHandle<()>, + netlink_handle: Handle, +} + +impl NetworkAgentActor { + async fn lookup_link_by_name( + &self, + link_name: &str, + ) -> Result { + self.netlink_handle + .link() + .get() + .match_name(link_name.to_string()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("link {} not found", link_name))? + .wrap_err_with(|| format!("failed to query link {}", link_name)) + } +} + +impl Actor for NetworkAgentActor { + type Args = NetworkConfig; + type Error = Report; + async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { + // do some netlink fuckery here + + let (connection, handle, _) = rtnetlink::new_connection()?; + let netlink_thread = tokio::spawn(connection); + + let common = match args.network_mode.clone() { + NetworkMode::HostonlyNat { + bridge, + subnet, + gateway: _, + upstream_iface: _, + } + | NetworkMode::Bridged { + bridge, + subnet, + gateway: _, + } => NetworkConfigCommon { + bridge, + subnet: subnet.to_string(), + }, + }; + + // ensure the bridge exists, creating it if necessary + let bridge = if let Some(bridge) = handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + { + bridge.wrap_err_with(|| format!("failed to query bridge {}", common.bridge))? + } else { + info!(bridge = %common.bridge, "creating new bridge"); + let new_bridge = LinkBridge::new(&common.bridge).up().build(); + handle + .link() + .add(new_bridge) + .execute() + .await + .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; + + handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? + .wrap_err_with(|| { + format!("failed to query bridge {} after creation", common.bridge) + })? + }; + + match args.network_mode.clone() { + NetworkMode::HostonlyNat { + bridge: _, + subnet, + gateway, + upstream_iface: _, + } + | NetworkMode::Bridged { + bridge: _, + subnet, + gateway, + } => { + ensure_address( + &handle, + bridge.header.index, + &common.bridge, + gateway, + subnet.prefix_len(), + ) + .await + .wrap_err_with(|| { + format!( + "failed to ensure gateway {}/{} exists on bridge {}", + gateway, + subnet.prefix_len(), + common.bridge + ) + })?; + } + } + + // let link_bridge = LinkBridge::new(arg) + + let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { + Some(DhcpActor::spawn_link(&actor_ref, dhcp_config.clone()).await) + } else { + None + }; + + Ok(Self { + config: args, + common, + dhcp_actor, + netlink_thread, + netlink_handle: handle, + }) + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + match reason { + ActorStopReason::Normal => { + info!(bridge = %self.common.bridge, "stopping network agent"); + } + ActorStopReason::Killed => { + info!(bridge = %self.common.bridge, "network agent killed"); + } + ActorStopReason::Panicked(err) => { + info!(bridge = %self.common.bridge, ?err, "network agent panicked"); + } + _ => { + info!(bridge = %self.common.bridge, "network agent stopping"); + } + } + + if let Some(dhcp_actor) = self.dhcp_actor.take() { + dhcp_actor.stop_gracefully().await?; + } + + self.netlink_thread.abort(); + let _ = (&mut self.netlink_thread).await; + + Ok(()) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: AttachTap, + _ctx: &mut Context, + ) -> Self::Reply { + let bridge = self + .lookup_link_by_name(&self.common.bridge) + .await + .wrap_err_with(|| { + format!( + "failed to resolve bridge {} before attaching tap {}", + self.common.bridge, msg.tap_name + ) + })?; + + let tap = self + .lookup_link_by_name(&msg.tap_name) + .await + .wrap_err_with(|| { + format!("failed to resolve tap {} for vm {}", msg.tap_name, msg.vmid) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "attaching tap to bridge" + ); + + self.netlink_handle + .link() + .set( + LinkUnspec::new_with_index(tap.header.index) + .controller(bridge.header.index) + .build(), + ) + .execute() + .await + .wrap_err_with(|| { + format!( + "failed to attach tap {} to bridge {}", + msg.tap_name, self.common.bridge + ) + })?; + + self.netlink_handle + .link() + .set(LinkUnspec::new_with_index(tap.header.index).up().build()) + .execute() + .await + .wrap_err_with(|| format!("failed to bring tap {} up", msg.tap_name))?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "tap attached to bridge successfully" + ); + + Ok(()) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: DetachTap, + _ctx: &mut Context, + ) -> Self::Reply { + let tap = self + .lookup_link_by_name(&msg.tap_name) + .await + .wrap_err_with(|| { + format!( + "failed to resolve tap {} for detach on vm {}", + msg.tap_name, msg.vmid + ) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "detaching tap from bridge" + ); + + self.netlink_handle + .link() + .set( + LinkUnspec::new_with_index(tap.header.index) + .nocontroller() + .build(), + ) + .execute() + .await + .wrap_err_with(|| { + format!( + "failed to detach tap {} from bridge {}", + msg.tap_name, self.common.bridge + ) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "tap detached from bridge successfully" + ); + + Ok(()) + } +} diff --git a/odorobo-agent/src/networking/messages.rs b/odorobo-agent/src/networking/messages.rs index a22403d..34a5d7c 100644 --- a/odorobo-agent/src/networking/messages.rs +++ b/odorobo-agent/src/networking/messages.rs @@ -1,4 +1,4 @@ -use stable_eyre::Result; +use ulid::Ulid; /// Ensures the agent-global networking state is present and configured. /// @@ -15,7 +15,7 @@ pub struct EnsureHostNetwork; #[derive(Debug, Clone)] pub struct AttachTap { /// VM identifier for logging and future bookkeeping. - pub vmid: String, + pub vmid: Ulid, /// Host TAP device name created for this VM by Cloud Hypervisor. pub tap_name: String, } @@ -27,7 +27,7 @@ pub struct AttachTap { #[derive(Debug, Clone)] pub struct DetachTap { /// VM identifier for logging and future bookkeeping. - pub vmid: String, + pub vmid: Ulid, /// Host TAP device name previously attached for this VM. pub tap_name: String, } @@ -51,10 +51,7 @@ pub struct NetworkStatus { #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttachedTap { /// VM identifier associated with the TAP. - pub vmid: String, + pub vmid: Ulid, /// Host TAP device name. pub tap_name: String, } - -/// Convenience alias for local networking actor replies. -pub type NetworkResult = Result; diff --git a/odorobo-agent/src/networking/mod.rs b/odorobo-agent/src/networking/mod.rs index ba63992..6608fae 100644 --- a/odorobo-agent/src/networking/mod.rs +++ b/odorobo-agent/src/networking/mod.rs @@ -1 +1,2 @@ +pub mod actor; pub mod messages; diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index e588c22..9023c6c 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -396,6 +396,7 @@ impl VMInstance { ); if let Ok(info) = self.info().await { trace!(vm_id = self.vm_id(), state = ?info.state, "Checking VM state before destroy"); + self.hook_manager.before_stop(self.vm_id(), &info).await?; if matches!( info.state, models::VmState::Running | models::VmState::Paused diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index 34f793a..1b5d217 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -12,6 +12,7 @@ use cloud_hypervisor_client::models::{VmConfig, VmInfo}; use stable_eyre::Result; mod machined; +mod networking; // Rust 1.75 does not support dyn async traits, we still need async_trait for this #[async_trait] @@ -92,7 +93,10 @@ impl HookManager { impl Default for HookManager { fn default() -> Self { Self { - hooks: vec![Box::new(machined::CHMachineProvisioningHook)], + hooks: vec![ + Box::new(machined::CHMachineProvisioningHook), + Box::new(networking::NetworkProvisioningHook), + ], } } } diff --git a/odorobo-agent/src/state/provisioning/hooks/networking.rs b/odorobo-agent/src/state/provisioning/hooks/networking.rs new file mode 100644 index 0000000..f8d3cec --- /dev/null +++ b/odorobo-agent/src/state/provisioning/hooks/networking.rs @@ -0,0 +1,100 @@ +//! Networking hook for provisioning. +use async_trait::async_trait; +use cloud_hypervisor_client::models::VmInfo; +use kameo::prelude::*; +use stable_eyre::{ + Result, + eyre::{WrapErr, eyre}, +}; +use ulid::Ulid; + +use crate::networking::{ + actor::NetworkAgentActor, + messages::{AttachTap, DetachTap}, +}; + +use super::ProvisioningHook; + +/// Provisioning hook that attaches Cloud Hypervisor TAP devices to the +/// agent-managed bridge after the VM has booted. +pub struct NetworkProvisioningHook; + +async fn network_agent() -> Result> { + ActorRef::::lookup("network_actor") + .await + .map_err(|e| eyre!(e))? + .ok_or_else(|| eyre!("network actor not found")) +} + +fn tap_names(info: &VmInfo) -> Vec { + info.config + .net + .as_ref() + .map(|nets| { + nets.iter() + .filter_map(|net| net.tap.clone()) + .collect::>() + }) + .unwrap_or_default() +} + +#[async_trait] +impl ProvisioningHook for NetworkProvisioningHook { + // CH auto-generates TAP names for each network device and create them if they don't + // already exist, so we are okay with the tap names we get from the VM config + async fn after_boot(&self, vmid: &str, config: &VmInfo) -> Result<()> { + let vmid = Ulid::from_string(vmid) + .map_err(|err| eyre!("invalid vmid {vmid}: {err}")) + .wrap_err("failed to parse vmid for networking hook")?; + + let taps = tap_names(config); + if taps.is_empty() { + return Ok(()); + } + + let network_actor = network_agent() + .await + .wrap_err("failed to look up network actor")?; + + for tap_name in taps { + network_actor + .ask(AttachTap { + vmid, + tap_name: tap_name.clone(), + }) + .await + .map_err(|err| eyre!(err)) + .wrap_err_with(|| format!("failed to send AttachTap for tap {tap_name}"))?; + } + + Ok(()) + } + + async fn before_stop(&self, vmid: &str, config: &VmInfo) -> Result<()> { + let vmid = Ulid::from_string(vmid) + .map_err(|err| eyre!("invalid vmid {vmid}: {err}")) + .wrap_err("failed to parse vmid for networking hook")?; + + let taps = tap_names(config); + if taps.is_empty() { + return Ok(()); + } + + let network_actor = network_agent() + .await + .wrap_err("failed to look up network actor")?; + + for tap_name in taps { + network_actor + .ask(DetachTap { + vmid, + tap_name: tap_name.clone(), + }) + .await + .map_err(|err| eyre!(err)) + .wrap_err_with(|| format!("failed to send DetachTap for tap {tap_name}"))?; + } + + Ok(()) + } +} From 137984e0444876bb1e921274c811bc78010a46e7 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 11:07:28 +0700 Subject: [PATCH 106/189] use nftables to write routing rules --- odorobo-agent/src/networking/actor.rs | 134 ++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 21 deletions(-) diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index e3da275..31b3d93 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -3,6 +3,7 @@ use crate::networking::messages::{AttachTap, DetachTap}; use futures_util::{StreamExt, TryStreamExt}; use kameo::{message::Context, prelude::*}; +use nftnl::{Batch, FinalizedBatch, Hook, MsgType, ProtoFamily, Rule, Table, nft_expr}; use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; use stable_eyre::Report; use stable_eyre::eyre::{Context as EyreContext, eyre}; @@ -21,6 +22,7 @@ impl Actor for DhcpActor { type Args = DhcpConfig; type Error = Report; async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + // todo: actually run dnsmasq on startup Ok(Self { config: args }) } } @@ -82,6 +84,7 @@ async fn ensure_address( }), } } + #[derive(RemoteActor)] pub struct NetworkAgentActor { pub config: NetworkConfig, @@ -90,6 +93,7 @@ pub struct NetworkAgentActor { // netlink_handle: netlink_thread: tokio::task::JoinHandle<()>, netlink_handle: Handle, + nft_socket: mnl::Socket, } impl NetworkAgentActor { @@ -107,6 +111,65 @@ impl NetworkAgentActor { .ok_or_else(|| eyre!("link {} not found", link_name))? .wrap_err_with(|| format!("failed to query link {}", link_name)) } + + fn send_nft_batch(&mut self, batch: &FinalizedBatch) -> Result<(), Report> { + let portid = self.nft_socket.portid(); + + self.nft_socket + .send_all(batch) + .wrap_err("failed to send nftables batch to netfilter")?; + + let mut buffer = vec![0; nftnl::nft_nlmsg_maxsize() as usize]; + let mut expected_seqs = batch.sequence_numbers(); + + while !expected_seqs.is_empty() { + for message in self + .nft_socket + .recv(&mut buffer[..]) + .wrap_err("failed to receive nftables netlink acknowledgement")? + { + let message = message.wrap_err("failed to decode nft ack message")?; + let expected_seq = expected_seqs + .next() + .ok_or_else(|| eyre!("received unexpected nftables acknowledgement"))?; + + mnl::cb_run(message, expected_seq, portid) + .wrap_err("nftables batch acknowledgement failed")?; + } + } + + Ok(()) + } + + fn ensure_nat_rules( + &mut self, + _bridge: &str, + _subnet: &str, + upstream_iface: &str, + ) -> Result<(), Report> { + let table = Table::new(c"nat", ProtoFamily::Ipv4); + + let mut postrouting_chain = nftnl::Chain::new(c"postrouting", &table); + postrouting_chain.set_type(nftnl::ChainType::Nat); + postrouting_chain.set_hook(Hook::PostRouting, 100); + + let mut batch = Batch::new(); + batch.add(&table, MsgType::Add); + batch.add(&postrouting_chain, MsgType::Add); + + let mut postrouting_rule = Rule::new(&postrouting_chain); + postrouting_rule.add_expr(&nft_expr!(meta oifname)); + postrouting_rule.add_expr(&nft_expr!(cmp == upstream_iface)); + postrouting_rule.add_expr(&nft_expr!(masquerade)); + batch.add(&postrouting_rule, MsgType::Add); + + let finalized = batch.finalize(); + self.send_nft_batch(&finalized).wrap_err_with(|| { + format!("failed to apply nftables postrouting masquerade for {upstream_iface}") + })?; + + Ok(()) + } } impl Actor for NetworkAgentActor { @@ -117,6 +180,8 @@ impl Actor for NetworkAgentActor { let (connection, handle, _) = rtnetlink::new_connection()?; let netlink_thread = tokio::spawn(connection); + let nft_socket = mnl::Socket::new(mnl::Bus::Netfilter) + .wrap_err("failed to create netfilter netlink socket")?; let common = match args.network_mode.clone() { NetworkMode::HostonlyNat { @@ -168,22 +233,63 @@ impl Actor for NetworkAgentActor { })? }; - match args.network_mode.clone() { + let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { + Some(DhcpActor::spawn_link(&actor_ref, dhcp_config.clone()).await) + } else { + None + }; + + let mut actor = Self { + config: args, + common, + dhcp_actor, + netlink_thread, + netlink_handle: handle, + nft_socket, + }; + + match actor.config.network_mode.clone() { NetworkMode::HostonlyNat { bridge: _, subnet, gateway, - upstream_iface: _, + upstream_iface, + } => { + ensure_address( + &actor.netlink_handle, + bridge.header.index, + &actor.common.bridge, + gateway, + subnet.prefix_len(), + ) + .await + .wrap_err_with(|| { + format!( + "failed to ensure gateway {}/{} exists on bridge {}", + gateway, + subnet.prefix_len(), + actor.common.bridge + ) + })?; + + actor + .ensure_nat_rules(&actor.common.bridge, &actor.common.subnet, &upstream_iface) + .wrap_err_with(|| { + format!( + "failed to ensure nftables NAT rules for bridge {} and upstream {}", + actor.common.bridge, upstream_iface + ) + })?; } - | NetworkMode::Bridged { + NetworkMode::Bridged { bridge: _, subnet, gateway, } => { ensure_address( - &handle, + &actor.netlink_handle, bridge.header.index, - &common.bridge, + &actor.common.bridge, gateway, subnet.prefix_len(), ) @@ -193,27 +299,13 @@ impl Actor for NetworkAgentActor { "failed to ensure gateway {}/{} exists on bridge {}", gateway, subnet.prefix_len(), - common.bridge + actor.common.bridge ) })?; } } - // let link_bridge = LinkBridge::new(arg) - - let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { - Some(DhcpActor::spawn_link(&actor_ref, dhcp_config.clone()).await) - } else { - None - }; - - Ok(Self { - config: args, - common, - dhcp_actor, - netlink_thread, - netlink_handle: handle, - }) + Ok(actor) } async fn on_stop( From 2bb9772dbb218d7cbe9a5d005ec0ffa1d3dca0d1 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 11:21:36 +0700 Subject: [PATCH 107/189] actually fix build issues, import mnl --- Cargo.lock | 12 +++++ odorobo-agent/Cargo.toml | 1 + odorobo-agent/src/networking/actor.rs | 63 +++++++++++++++++---------- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82b5e1f..1676414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2709,6 +2709,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mnl" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ab362254419b09757228dc96e0d834e75392c52870d759df2274753007065" +dependencies = [ + "libc", + "log", + "mnl-sys", +] + [[package]] name = "mnl-sys" version = "0.2.2" @@ -3005,6 +3016,7 @@ dependencies = [ "kameo", "libc", "libp2p", + "mnl", "nftnl", "odorobo-shared", "random-port", diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index a9ef5bf..40ccce9 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -48,4 +48,5 @@ ulid = { workspace = true } ipnet = { workspace = true } rtnetlink = "0.20.0" nftnl = { version = "0.9.1", features = ["nftnl-1-1-3"] } +mnl = "0.3.1" diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 31b3d93..81cf27a 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -3,10 +3,13 @@ use crate::networking::messages::{AttachTap, DetachTap}; use futures_util::{StreamExt, TryStreamExt}; use kameo::{message::Context, prelude::*}; -use nftnl::{Batch, FinalizedBatch, Hook, MsgType, ProtoFamily, Rule, Table, nft_expr}; +use nftnl::{ + Batch, FinalizedBatch, Hook, MsgType, ProtoFamily, Rule, Table, expr::InterfaceName, nft_expr, +}; use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; use stable_eyre::Report; use stable_eyre::eyre::{Context as EyreContext, eyre}; +use std::ffi::CString; use tracing::info; pub struct DhcpActor { @@ -93,7 +96,6 @@ pub struct NetworkAgentActor { // netlink_handle: netlink_thread: tokio::task::JoinHandle<()>, netlink_handle: Handle, - nft_socket: mnl::Socket, } impl NetworkAgentActor { @@ -112,10 +114,12 @@ impl NetworkAgentActor { .wrap_err_with(|| format!("failed to query link {}", link_name)) } - fn send_nft_batch(&mut self, batch: &FinalizedBatch) -> Result<(), Report> { - let portid = self.nft_socket.portid(); + fn send_nft_batch(batch: &FinalizedBatch) -> Result<(), Report> { + let socket = mnl::Socket::new(mnl::Bus::Netfilter) + .wrap_err("failed to create netfilter netlink socket")?; + let portid = socket.portid(); - self.nft_socket + socket .send_all(batch) .wrap_err("failed to send nftables batch to netfilter")?; @@ -123,8 +127,7 @@ impl NetworkAgentActor { let mut expected_seqs = batch.sequence_numbers(); while !expected_seqs.is_empty() { - for message in self - .nft_socket + for message in socket .recv(&mut buffer[..]) .wrap_err("failed to receive nftables netlink acknowledgement")? { @@ -141,12 +144,23 @@ impl NetworkAgentActor { Ok(()) } - fn ensure_nat_rules( - &mut self, - _bridge: &str, - _subnet: &str, - upstream_iface: &str, - ) -> Result<(), Report> { + /// Ensures the host-only NAT masquerade rule exists for the configured + /// upstream interface. + /// + /// This is intentionally IPv4-only for now. + /// + /// The current host-only networking model is built around IPv4 guest + /// addressing and an IPv4 bridge gateway: + /// - guest subnet config uses `Ipv4Net` + /// - bridge gateway config uses `Ipv4Addr` + /// - bridge address assignment is currently IPv4-only + /// + /// Although nftables can match by interface in `inet` tables, switching + /// this masquerade rule to dual-stack would implicitly opt us into IPv6 + /// NAT/NAT66 policy as well. That is a larger design decision than this + /// hook should make on its own. Until odorobo has an intentional IPv6 + /// guest-networking story, we keep host-only NAT scoped to IPv4. + fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { let table = Table::new(c"nat", ProtoFamily::Ipv4); let mut postrouting_chain = nftnl::Chain::new(c"postrouting", &table); @@ -158,14 +172,18 @@ impl NetworkAgentActor { batch.add(&postrouting_chain, MsgType::Add); let mut postrouting_rule = Rule::new(&postrouting_chain); + let upstream_iface = InterfaceName::Exact( + CString::new(upstream_iface) + .wrap_err("upstream interface name contained interior NUL")?, + ); postrouting_rule.add_expr(&nft_expr!(meta oifname)); - postrouting_rule.add_expr(&nft_expr!(cmp == upstream_iface)); + postrouting_rule.add_expr(&nft_expr!(cmp == &upstream_iface)); postrouting_rule.add_expr(&nft_expr!(masquerade)); batch.add(&postrouting_rule, MsgType::Add); let finalized = batch.finalize(); - self.send_nft_batch(&finalized).wrap_err_with(|| { - format!("failed to apply nftables postrouting masquerade for {upstream_iface}") + Self::send_nft_batch(&finalized).wrap_err_with(|| { + format!("failed to apply nftables postrouting masquerade for {upstream_iface:?}") })?; Ok(()) @@ -180,8 +198,6 @@ impl Actor for NetworkAgentActor { let (connection, handle, _) = rtnetlink::new_connection()?; let netlink_thread = tokio::spawn(connection); - let nft_socket = mnl::Socket::new(mnl::Bus::Netfilter) - .wrap_err("failed to create netfilter netlink socket")?; let common = match args.network_mode.clone() { NetworkMode::HostonlyNat { @@ -239,15 +255,17 @@ impl Actor for NetworkAgentActor { None }; - let mut actor = Self { + let actor = Self { config: args, common, dhcp_actor, netlink_thread, netlink_handle: handle, - nft_socket, }; + let common_bridge = actor.common.bridge.clone(); + let common_subnet = actor.common.subnet.clone(); + match actor.config.network_mode.clone() { NetworkMode::HostonlyNat { bridge: _, @@ -272,12 +290,11 @@ impl Actor for NetworkAgentActor { ) })?; - actor - .ensure_nat_rules(&actor.common.bridge, &actor.common.subnet, &upstream_iface) + Self::ensure_nat_rules(&common_bridge, &common_subnet, &upstream_iface) .wrap_err_with(|| { format!( "failed to ensure nftables NAT rules for bridge {} and upstream {}", - actor.common.bridge, upstream_iface + common_bridge, upstream_iface ) })?; } From 032d14184efc8fe2f2a55686687abd89c0348b4c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 11:40:14 +0700 Subject: [PATCH 108/189] idempotent nat --- odorobo-agent/src/networking/actor.rs | 66 +++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 81cf27a..a3d4339 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -10,6 +10,7 @@ use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; use stable_eyre::Report; use stable_eyre::eyre::{Context as EyreContext, eyre}; use std::ffi::CString; +use std::process::Command; use tracing::info; pub struct DhcpActor { @@ -144,6 +145,45 @@ impl NetworkAgentActor { Ok(()) } + // use the nft CLI instead because doing full introspection is kind of a pain + fn nft_table_exists(table: &str) -> Result { + let output = Command::new("nft") + .args(["list", "table", "ip", table]) + .output() + .wrap_err_with(|| format!("failed to query nft table ip {table}"))?; + + Ok(output.status.success()) + } + + fn nft_chain_exists(table: &str, chain: &str) -> Result { + let output = Command::new("nft") + .args(["list", "chain", "ip", table, chain]) + .output() + .wrap_err_with(|| format!("failed to query nft chain ip {table} {chain}"))?; + + Ok(output.status.success()) + } + + fn nft_postrouting_masquerade_exists( + table: &str, + chain: &str, + upstream_iface: &str, + ) -> Result { + let output = Command::new("nft") + .args(["list", "chain", "ip", table, chain]) + .output() + .wrap_err_with(|| format!("failed to inspect nft chain ip {table} {chain}"))?; + + if !output.status.success() { + return Ok(false); + } + + let stdout = String::from_utf8(output.stdout) + .wrap_err_with(|| format!("failed to decode nft output for ip {table} {chain}"))?; + + Ok(stdout.contains(&format!("oifname \"{upstream_iface}\" masquerade"))) + } + /// Ensures the host-only NAT masquerade rule exists for the configured /// upstream interface. /// @@ -161,15 +201,35 @@ impl NetworkAgentActor { /// hook should make on its own. Until odorobo has an intentional IPv6 /// guest-networking story, we keep host-only NAT scoped to IPv4. fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { - let table = Table::new(c"nat", ProtoFamily::Ipv4); + const TABLE_NAME: &str = "odorobo"; + const CHAIN_NAME: &str = "postrouting"; + + let table_exists = Self::nft_table_exists(TABLE_NAME)?; + let chain_exists = if table_exists { + Self::nft_chain_exists(TABLE_NAME, CHAIN_NAME)? + } else { + false + }; + + if chain_exists + && Self::nft_postrouting_masquerade_exists(TABLE_NAME, CHAIN_NAME, upstream_iface)? + { + return Ok(()); + } + + let table = Table::new(c"odorobo", ProtoFamily::Ipv4); let mut postrouting_chain = nftnl::Chain::new(c"postrouting", &table); postrouting_chain.set_type(nftnl::ChainType::Nat); postrouting_chain.set_hook(Hook::PostRouting, 100); let mut batch = Batch::new(); - batch.add(&table, MsgType::Add); - batch.add(&postrouting_chain, MsgType::Add); + if !table_exists { + batch.add(&table, MsgType::Add); + } + if !chain_exists { + batch.add(&postrouting_chain, MsgType::Add); + } let mut postrouting_rule = Rule::new(&postrouting_chain); let upstream_iface = InterfaceName::Exact( From 6b5d1dcca09964c75d0d02fbc192aa7d1dfa1813 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 11:46:53 +0700 Subject: [PATCH 109/189] rename chain to guest_nat --- odorobo-agent/src/networking/actor.rs | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index a3d4339..1886c58 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -200,9 +200,36 @@ impl NetworkAgentActor { /// NAT/NAT66 policy as well. That is a larger design decision than this /// hook should make on its own. Until odorobo has an intentional IPv6 /// guest-networking story, we keep host-only NAT scoped to IPv4. + /// + // todo: IPv6, refer to libvirt's impl: + // ```nft + // table ip6 libvirt_network { + // chain forward { + // type filter hook forward priority filter; policy accept; + // counter packets 0 bytes 0 jump guest_cross + // counter packets 0 bytes 0 jump guest_input + // counter packets 0 bytes 0 jump guest_output + // } + + // chain guest_output { + // } + + // chain guest_input { + // } + + // chain guest_cross { + // } + + // chain guest_nat { + // type nat hook postrouting priority srcnat; policy accept; + // } + // } + // ``` + + fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { const TABLE_NAME: &str = "odorobo"; - const CHAIN_NAME: &str = "postrouting"; + const CHAIN_NAME: &str = "guest_nat"; let table_exists = Self::nft_table_exists(TABLE_NAME)?; let chain_exists = if table_exists { @@ -219,7 +246,7 @@ impl NetworkAgentActor { let table = Table::new(c"odorobo", ProtoFamily::Ipv4); - let mut postrouting_chain = nftnl::Chain::new(c"postrouting", &table); + let mut postrouting_chain = nftnl::Chain::new(c"guest_nat", &table); postrouting_chain.set_type(nftnl::ChainType::Nat); postrouting_chain.set_hook(Hook::PostRouting, 100); From 9c4fa5c709095b3a9a52a5af3cd52a98516170b0 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 12:52:29 +0700 Subject: [PATCH 110/189] spawn dnsmasq --- odorobo-agent/src/actor/mod.rs | 2 +- odorobo-agent/src/networking/actor.rs | 62 ++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 07e826d..c5eb6c1 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -73,7 +73,7 @@ fn default_upstream_iface() -> String { pub struct DhcpConfig { pub upstream_iface: String, pub range: (Ipv4Addr, Ipv4Addr), - pub mask: Ipv4Net, + pub subnet: Ipv4Net, /// lease time for DHCP clients /// /// example: 12h, 6h, 30m diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 1886c58..4be62b3 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -15,6 +15,8 @@ use tracing::info; pub struct DhcpActor { pub config: DhcpConfig, + bridge: String, + dnsmasq_process: Option, } pub struct NetworkConfigCommon { @@ -23,11 +25,49 @@ pub struct NetworkConfigCommon { } impl Actor for DhcpActor { - type Args = DhcpConfig; + type Args = (DhcpConfig, String); type Error = Report; async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { - // todo: actually run dnsmasq on startup - Ok(Self { config: args }) + let (config, bridge) = args; + let dhcp_range = format!( + "{},{},{},{}", + config.range.0, + config.range.1, + config.subnet.netmask(), + config.lease_time + ); + + let dnsmasq_process = tokio::process::Command::new("dnsmasq") + .arg("--interface") + .arg(&bridge) + .arg("--bind-interfaces") + .arg("--dhcp-range") + .arg(&dhcp_range) + .arg("--no-daemon") + .spawn() + .wrap_err_with(|| format!("failed to start dnsmasq on bridge {bridge}"))?; + + Ok(Self { + config, + bridge, + dnsmasq_process: Some(dnsmasq_process), + }) + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + _reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + if let Some(mut dnsmasq_process) = self.dnsmasq_process.take() { + dnsmasq_process + .start_kill() + .wrap_err_with(|| format!("failed to stop dnsmasq on bridge {}", self.bridge))?; + + let _ = dnsmasq_process.wait().await; + } + + Ok(()) } } @@ -200,7 +240,7 @@ impl NetworkAgentActor { /// NAT/NAT66 policy as well. That is a larger design decision than this /// hook should make on its own. Until odorobo has an intentional IPv6 /// guest-networking story, we keep host-only NAT scoped to IPv4. - /// + /// // todo: IPv6, refer to libvirt's impl: // ```nft // table ip6 libvirt_network { @@ -210,22 +250,21 @@ impl NetworkAgentActor { // counter packets 0 bytes 0 jump guest_input // counter packets 0 bytes 0 jump guest_output // } - + // chain guest_output { // } - + // chain guest_input { // } - + // chain guest_cross { // } - + // chain guest_nat { // type nat hook postrouting priority srcnat; policy accept; // } // } // ``` - fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { const TABLE_NAME: &str = "odorobo"; @@ -337,7 +376,10 @@ impl Actor for NetworkAgentActor { }; let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { - Some(DhcpActor::spawn_link(&actor_ref, dhcp_config.clone()).await) + Some( + DhcpActor::spawn_link(&actor_ref, (dhcp_config.clone(), common.bridge.clone())) + .await, + ) } else { None }; From 21fd51ad3098650ad28b5f89e80d4648a49e459a Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 16 Apr 2026 21:53:33 +0700 Subject: [PATCH 111/189] fix proper networking routes, document console and network transformer --- Containerfile | 4 +- README.md | 13 +- docs/console.md | 81 ++++++-- docs/networking.md | 83 ++++++++ odorobo-agent/config.json | 18 +- odorobo-agent/src/actor/mod.rs | 45 +++- odorobo-agent/src/networking/actor.rs | 157 +++++++++++--- odorobo-agent/src/state/instance.rs | 5 +- .../src/state/provisioning/hooks/mod.rs | 13 ++ .../state/provisioning/hooks/networking.rs | 20 +- odorobo-agent/src/state/transform/mod.rs | 3 + .../src/state/transform/networking.rs | 195 ++++++++++++++++++ 12 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 docs/networking.md create mode 100644 odorobo-agent/src/state/transform/networking.rs diff --git a/Containerfile b/Containerfile index 40d75dc..6f012a2 100644 --- a/Containerfile +++ b/Containerfile @@ -4,7 +4,9 @@ RUN apk add \ gcc \ musl-dev \ rustup \ - zig + zig \ + libnftnl-dev \ + libmnl-dev RUN rustup-init \ --profile minimal \ --target riscv64a23-unknown-linux-gnu \ diff --git a/README.md b/README.md index 0bd450a..0dce614 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,19 @@ Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/clo odoroboctl create my-vm --boot ./my-vm.json ``` -To connect directly on the host, look up the PTY path from the VM info: +To connect directly on the host, connect to the VM's serial console socket in its runtime directory: ```bash -odoroboctl info my-vm # find config.serial.file, e.g. /dev/pts/3 -screen /dev/pts/3 +sudo socat -,rawer UNIX-CONNECT:/run/odorobo/vms/01KPBBXKK0R0M09VN7G6R6R3JF/console.sock ``` -See [docs/console.md](docs/console.md) for WebSocket console usage and integration details. +Replace the VM ID in the socket path with your VM's ID. The serial console socket is created at: + +```text +/run/odorobo/vms//console.sock +``` + +See [docs/console.md](docs/console.md) for direct serial socket access, WebSocket console usage, and integration details. For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API diff --git a/docs/console.md b/docs/console.md index 5b7a17d..c09eabc 100644 --- a/docs/console.md +++ b/docs/console.md @@ -1,39 +1,74 @@ -# Console API +# Console Integration -## PTY over WebSocket +Odorobo configures the guest serial console as a UNIX socket owned by the agent rather than relying on a host PTY allocated by Cloud Hypervisor. +This gives each VM a stable, deterministic host-side serial endpoint under the VM runtime directory. + +Currently, Odorobo's console integration supports the following access patterns: + +- **Direct host-side serial socket access**: Each VM's serial console is configured as a UNIX socket at `/run/odorobo/vms//console.sock`. This can be connected to directly from the host with tools such as `socat`. +- **WebSocket console bridge**: The agent exposes a WebSocket endpoint at `GET /vms/{vmid}/console` and proxies the VM's serial socket in both directions for browser or CLI clients. + +## Serial socket configuration + +During VM config transformation, Odorobo rewrites the serial console configuration to use Cloud Hypervisor's socket-backed serial mode. +For a VM with ID `01KPBBXKK0R0M09VN7G6R6R3JF`, the serial console socket will be created at: + +```text +/run/odorobo/vms/01KPBBXKK0R0M09VN7G6R6R3JF/console.sock +``` + +This path is stable for the lifetime of the VM runtime directory and is derived from the VM ID. + +## Direct host access + +To connect directly to the guest serial console on the host, connect to the socket with `socat`: + +```bash +sudo socat -,rawer UNIX-CONNECT:/run/odorobo/vms/01KPBBXKK0R0M09VN7G6R6R3JF/console.sock +``` + +This is currently the simplest direct way to verify that the guest serial console is alive and accepting input. + +A few notes about this connection style: + +- `rawer` is important so local terminal processing does not interfere with the serial byte stream. +- You will typically need sufficient permissions to access the runtime directory and socket, which is why `sudo` is commonly required. +- This is a raw serial stream, not a terminal emulator. Line editing, ANSI handling, scrollback, and rendering are provided by your terminal, not by Odorobo. + +## WebSocket bridge The agent exposes a VM console bridge at: `GET /vms/{vmid}/console` -This endpoint upgrades to a WebSocket and proxies the VM's serial console PTY in both directions. Cloud Hypervisor allocates a PTY for the serial console; the agent looks up the PTY path via the CH API and opens it on behalf of the client. +This endpoint upgrades to a WebSocket and proxies the VM's serial console socket in both directions. ## Connection behavior - Connect with a standard WebSocket client to `ws://:8890/vms//console` -- The upgrade succeeds only if the VM exists and the agent can open its console PTY +- The upgrade succeeds only if the VM exists and the agent can open its serial console socket - If the VM does not exist, the HTTP request returns `404` -- If the PTY cannot be opened, the HTTP request returns `500` +- If the serial socket cannot be opened, the HTTP request returns `500` - After the upgrade completes, terminal bytes flow over the socket until either side disconnects +In practice, treat this as a raw byte stream carried over WebSocket frames. + ## Frame semantics - Client -> VM: - - WebSocket `Binary` frames are written to the PTY as-is + - WebSocket `Binary` frames are written to the serial socket as-is - WebSocket `Text` frames are reserved for JSON control messages - VM -> Client: - - A `{"type":"connected","vm_id":"..."}` text frame is sent immediately on upgrade before any PTY data - - PTY output is sent back as WebSocket `Binary` frames + - A `{"type":"connected","vm_id":"..."}` text frame is sent immediately on upgrade before any console data + - Serial console output is sent back as WebSocket `Binary` frames - Server control or protocol errors are sent as WebSocket `Text` frames containing JSON - Control frames: - WebSocket `Ping` receives a `Pong` - WebSocket `Close` closes the session -In practice, treat this as a raw byte stream carried over WebSocket frames. - ## Resize message -Clients resize the PTY by sending a JSON text frame like: +Clients resize the console session by sending a JSON text frame like: ```json {"type":"resize","cols":120,"rows":40} @@ -45,7 +80,7 @@ Optional pixel dimensions are also supported: {"type":"resize","cols":120,"rows":40,"x_pixels":960,"y_pixels":720} ``` -The agent applies the new TTY window size on the host PTY with `TIOCSWINSZ`, so the guest can observe the updated size through normal terminal mechanisms. +The agent applies the new terminal window size on the host-side console endpoint so the guest can observe the updated size through normal terminal mechanisms. ## Reset-session message @@ -55,7 +90,7 @@ Clients can ask the agent to try to return the console to a fresh login-like sta {"type":"reset_session"} ``` -This sends a conservative control sequence to the PTY: +This sends a conservative control sequence to the serial console: - `Ctrl-C` to interrupt a foreground shell command if possible - `Enter` to try to land on a clean prompt @@ -65,16 +100,16 @@ This is best-effort only. The guest decides what those bytes mean. ## Important implementation notes -- Do not assume one terminal message maps to one WebSocket frame; PTY output is chunked arbitrarily +- Do not assume one terminal message maps to one WebSocket frame; serial output is chunked arbitrarily - Send terminal input as binary bytes, not text frames - Text frames should be treated as a small control channel for messages like resize requests, reset requests, and error events - If the server receives an invalid control message, it responds with a JSON text frame like `{"type":"error","message":"..."}` and keeps the session open - This API is transport-only; terminal emulation, ANSI parsing, scrollback, and rendering are client responsibilities -- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on `hvc0` +- `reset_session` is heuristic: it works best when the guest runs a normal login shell or `agetty` on the configured serial console - On serial-backed Linux sessions, `Ctrl-D` only causes a logout when the foreground process interprets it as EOF - If the guest is running `vim`, `less`, a full-screen app, or a raw-mode program, `reset_session` may not produce a fresh login prompt -- For deterministic fresh sessions, configure the guest to respawn `getty` on the console device after shell exit -- The PTY path changes if the VM is restarted or migrated; the WebSocket connection must be re-established after migration +- For deterministic fresh sessions, configure the guest to respawn `getty` on the serial console after shell exit +- The console socket path is stable for a given VM runtime directory, but the socket itself is recreated when the VM is restarted or migrated, so clients must reconnect after those events ## Browser example @@ -85,7 +120,6 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); ws.addEventListener("open", () => { - // Send a command to the guest console. ws.send(encoder.encode("uname -a\n")); ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 })); }); @@ -133,4 +167,13 @@ function resetSession() { websocat --binary ws://127.0.0.1:8890/vms/my-vm/console ``` -This is the simplest way to verify the bridge works before integrating it into a browser terminal such as xterm.js. +This is the simplest way to verify the WebSocket bridge works before integrating it into a browser terminal such as xterm.js. + +## Summary + +For most debugging and operator workflows: + +- use `socat` when you are already on the host and want a direct raw serial connection +- use the WebSocket bridge when you want remote access or browser integration + +Both paths ultimately connect to the same guest serial console socket managed by the agent. \ No newline at end of file diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..ce3d18c --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,83 @@ +# Networking Integration + +Odorobo adds a networking integration layer on top of Cloud Hypervisor's network device support, using config transforms to convert Odorobo-local network references into concrete TAP device names that Cloud Hypervisor can use directly. +This allows users to specify guest network attachments in a stable, Odorobo-aware way, while letting the agent derive deterministic host-side interface names instead of relying on Cloud Hypervisor to auto-generate TAP names at runtime. + +Currently, Odorobo's networking transform supports the following backend: + +- **Odorobo-managed TAP naming**: Specify a network attachment by setting the network device `id` to the `net://` URI scheme, e.g. `net://devnet`. Odorobo will detect the `net://` scheme during config transformation, validate the network identifier, and rewrite the Cloud Hypervisor `NetConfig` to use a deterministic TAP device name derived from the VM ID and the network ID. + +To use the networking transformer, specify the desired `net://` URI in the `id` field of a network device in your VM config. For example: + +```json +{ + ... + "net": [ + { + "id": "net://devnet", + "mac": "46:59:52:67:67:67" + } + ] +} +``` + +Odorobo will automatically detect the `net://` scheme and transform it before VM creation into a concrete Cloud Hypervisor network device definition. For a VM with ID `01KPB9AMVWXC2D1PF4W6E7MHWT`, the transformed config would look like: + +```json +{ + ... + "net": [ + { + "id": "devnet", + "tap": "vmtap-7mhwt-vnet", + "mac": "46:59:52:67:67:67" + } + ] +} +``` + +The transformed `tap` field gives Cloud Hypervisor a known host-side TAP name to use, and the transformed `id` field removes the Odorobo-local `net://` prefix so the runtime configuration contains only the concrete network identifier. + +The TAP name is deterministic per `(vmid, network_id)` pair and is generated using the following rules: + +- The TAP name format is `vmtap--`. +- Linux interface names are limited in length, so Odorobo truncates both the VM ID and network ID components to fit within the interface name limit. +- Odorobo keeps the **suffix** of the VM ID rather than the prefix, because ULID prefixes are timestamp-derived and less useful for uniqueness when many VMs are created close together. +- The network ID is sanitized to a conservative Linux-interface-safe character set. +- Network IDs may only contain ASCII alphanumeric characters, `_`, and `-`. + +This transform currently only rewrites the VM configuration. +It does not yet imply more advanced network orchestration semantics such as routed IPv6 guest networking, dynamic TAP discovery from Cloud Hypervisor, or arbitrary external network backend resolution. + +## Network modes + +Odorobo's agent-side network configuration currently supports two host networking modes: + +- **Host-only NAT**: A private guest bridge with a host-side gateway IP and outbound NAT through a configured upstream interface. +- **Bridged**: A flat bridge mode where the agent ensures the bridge exists and has the configured host address, but the operator is responsible for attaching any physical uplink to the bridge. + +In Host-only NAT mode, the bridge itself carries the guest gateway IP on the private subnet, and guest TAP devices are attached to that bridge. +Outbound NAT is currently implemented as an IPv4-only masquerade rule in a dedicated Odorobo-owned nftables table. + +> [!NOTE] +> Host-only NAT is intentionally IPv4-only for now. +> +> Although nftables can match interfaces in dual-stack tables, Odorobo's current host-only networking model is explicitly built around IPv4 guest subnet and gateway configuration. +> Enabling IPv6 NAT would be a larger policy decision, because it would implicitly opt Odorobo into an IPv6 guest-networking story and NAT66 behavior. +> Until Odorobo has intentional IPv6 guest-network design, host-only NAT remains scoped to IPv4. + +## DHCP integration + +If DHCP is configured in the agent's networking config, Odorobo starts `dnsmasq` bound to the configured guest bridge and serves leases for the configured subnet and range. +This is intended for host-only private guest networks where Odorobo owns the bridge and guest-side address distribution. + +## Limitations + +At the moment, the `net://` transformer is intentionally conservative: + +- It only interprets `net://` in the network device `id` field. +- It does not currently resolve arbitrary remote or provider-backed virtual networks. +- It does not currently create a higher-level virtual network inventory or multi-tenant network object model. +- It relies on deterministic TAP naming instead of Cloud Hypervisor runtime TAP discovery, because Cloud Hypervisor does not reliably reflect auto-generated TAP device names back into VM info after boot. + +This makes the current networking integration a practical first step: Odorobo can transform user-friendly network references into stable Cloud Hypervisor network config while keeping host-side naming deterministic and debuggable. \ No newline at end of file diff --git a/odorobo-agent/config.json b/odorobo-agent/config.json index bc5ed7e..a8017f3 100644 --- a/odorobo-agent/config.json +++ b/odorobo-agent/config.json @@ -1,4 +1,18 @@ { "datacenter": "Dev", - "region": "Dev" -} \ No newline at end of file + "region": "Dev", + "network": { + "dhcp_config": { + "range": ["10.10.100.100", "10.10.100.200"], + "subnet": "10.10.100.0/24", + "lease_time": "12h" + }, + "network_mode": { + "HostonlyNat": { + "bridge": "vmbr0", + "subnet": "10.10.100.0/24", + "gateway": "10.10.100.1" + } + } + } +} diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index c5eb6c1..f7ddfce 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -7,6 +7,7 @@ use odorobo_shared::{ messages::{Ping, Pong, debug::PanicAgent, vm::*}, utils::vm_actor_id, }; +use rtnetlink::packet_route::route::RouteMessage; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::ops::ControlFlow; @@ -58,9 +59,19 @@ fn default_subnet() -> Ipv4Net { fn default_gateway() -> Ipv4Addr { "10.0.0.1".parse().unwrap() } - +/// Infers the default upstream interface from the system's default route fn default_upstream_iface() -> String { - "eth0".into() + // ip route + let out = std::process::Command::new("ip") + .arg("route") + .output() + .unwrap(); + let output = String::from_utf8(out.stdout).unwrap(); + + let default_route = output.lines().find(|l| l.starts_with("default")).unwrap(); + let iface = default_route.split_whitespace().nth(4).unwrap(); + info!("inferring default upstream interface: {}", iface); + iface.into() } /// DHCP server config @@ -71,7 +82,6 @@ fn default_upstream_iface() -> String { // --no-daemon #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DhcpConfig { - pub upstream_iface: String, pub range: (Ipv4Addr, Ipv4Addr), pub subnet: Ipv4Net, /// lease time for DHCP clients @@ -131,7 +141,7 @@ impl Default for NetworkMode { // The infra team wants a config file on the box where they can set info specific for the box its on. // TODO: Double check with infra team (katherine) if they want any other config on the box. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Default)] pub struct Config { /// The hostname of the agent. Defaults to the system hostname /// if not specified in the config file. @@ -389,4 +399,29 @@ impl Message for AgentActor { } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + #[test] + fn test_config_serialize() { + let config = Config { + network: NetworkConfig { + dhcp_config: Some(DhcpConfig { + range: (Ipv4Addr::new(10, 10, 1, 100), Ipv4Addr::new(10, 10, 1, 200)), + subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 1, 0), 24).unwrap(), + lease_time: "12h".to_string(), + }), + network_mode: NetworkMode::HostonlyNat { + bridge: "vmbr0".to_string(), + gateway: Ipv4Addr::new(10, 10, 100, 1), + subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 100, 0), 24).unwrap(), + upstream_iface: default_upstream_iface(), + }, + }, + ..Default::default() + }; + + let json = serde_json::to_string_pretty(&config).unwrap(); + // assert_eq!(json, ) + println!("{}", json); + } +} diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 4be62b3..63aa338 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -22,6 +22,7 @@ pub struct DhcpActor { pub struct NetworkConfigCommon { pub bridge: String, pub subnet: String, + pub upstream_iface: Option, } impl Actor for DhcpActor { @@ -37,6 +38,15 @@ impl Actor for DhcpActor { config.lease_time ); + info!( + bridge = %bridge, + range_start = %config.range.0, + range_end = %config.range.1, + subnet = %config.subnet, + lease_time = %config.lease_time, + "starting dnsmasq for guest DHCP" + ); + let dnsmasq_process = tokio::process::Command::new("dnsmasq") .arg("--interface") .arg(&bridge) @@ -160,6 +170,8 @@ impl NetworkAgentActor { .wrap_err("failed to create netfilter netlink socket")?; let portid = socket.portid(); + info!("sending nftables batch to netfilter"); + socket .send_all(batch) .wrap_err("failed to send nftables batch to netfilter")?; @@ -270,6 +282,13 @@ impl NetworkAgentActor { const TABLE_NAME: &str = "odorobo"; const CHAIN_NAME: &str = "guest_nat"; + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + upstream_iface = upstream_iface, + "ensuring host-only NAT masquerade rule exists" + ); + let table_exists = Self::nft_table_exists(TABLE_NAME)?; let chain_exists = if table_exists { Self::nft_chain_exists(TABLE_NAME, CHAIN_NAME)? @@ -280,6 +299,12 @@ impl NetworkAgentActor { if chain_exists && Self::nft_postrouting_masquerade_exists(TABLE_NAME, CHAIN_NAME, upstream_iface)? { + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + upstream_iface = upstream_iface, + "existing NAT masquerade rule already present" + ); return Ok(()); } @@ -291,9 +316,15 @@ impl NetworkAgentActor { let mut batch = Batch::new(); if !table_exists { + info!(table = TABLE_NAME, "creating odorobo nftables table"); batch.add(&table, MsgType::Add); } if !chain_exists { + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + "creating odorobo nftables chain" + ); batch.add(&postrouting_chain, MsgType::Add); } @@ -330,49 +361,83 @@ impl Actor for NetworkAgentActor { bridge, subnet, gateway: _, - upstream_iface: _, - } - | NetworkMode::Bridged { + upstream_iface, + } => NetworkConfigCommon { + bridge, + subnet: subnet.to_string(), + upstream_iface: Some(upstream_iface), + }, + NetworkMode::Bridged { bridge, subnet, gateway: _, } => NetworkConfigCommon { bridge, subnet: subnet.to_string(), + upstream_iface: None, }, }; // ensure the bridge exists, creating it if necessary - let bridge = if let Some(bridge) = handle + let bridge_lookup = handle .link() .get() .match_name(common.bridge.clone()) .execute() .next() - .await - { - bridge.wrap_err_with(|| format!("failed to query bridge {}", common.bridge))? - } else { - info!(bridge = %common.bridge, "creating new bridge"); - let new_bridge = LinkBridge::new(&common.bridge).up().build(); - handle - .link() - .add(new_bridge) - .execute() - .await - .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; - - handle - .link() - .get() - .match_name(common.bridge.clone()) - .execute() - .next() - .await - .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? - .wrap_err_with(|| { - format!("failed to query bridge {} after creation", common.bridge) - })? + .await; + + let bridge = match bridge_lookup { + Some(Ok(bridge)) => bridge, + Some(Err(err)) if matches!(err, NetlinkError::NetlinkError(ref nl_err) if nl_err.raw_code() == -libc::ENODEV) => + { + info!(bridge = %common.bridge, "bridge not found during lookup, creating it"); + let new_bridge = LinkBridge::new(&common.bridge).up().build(); + handle + .link() + .add(new_bridge) + .execute() + .await + .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; + + handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? + .wrap_err_with(|| { + format!("failed to query bridge {} after creation", common.bridge) + })? + } + Some(Err(err)) => { + return Err(err) + .wrap_err_with(|| format!("failed to query bridge {}", common.bridge)); + } + None => { + info!(bridge = %common.bridge, "creating new bridge"); + let new_bridge = LinkBridge::new(&common.bridge).up().build(); + handle + .link() + .add(new_bridge) + .execute() + .await + .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; + + handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? + .wrap_err_with(|| { + format!("failed to query bridge {} after creation", common.bridge) + })? + } }; let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { @@ -395,13 +460,27 @@ impl Actor for NetworkAgentActor { let common_bridge = actor.common.bridge.clone(); let common_subnet = actor.common.subnet.clone(); + info!( + bridge = %common_bridge, + subnet = %common_subnet, + upstream_iface = ?actor.common.upstream_iface, + "network actor startup configuration resolved" + ); + match actor.config.network_mode.clone() { NetworkMode::HostonlyNat { bridge: _, subnet, gateway, - upstream_iface, + upstream_iface: _, } => { + info!( + bridge = %actor.common.bridge, + gateway = %gateway, + prefix_len = subnet.prefix_len(), + "ensuring host-only NAT bridge gateway address" + ); + ensure_address( &actor.netlink_handle, bridge.header.index, @@ -419,7 +498,18 @@ impl Actor for NetworkAgentActor { ) })?; - Self::ensure_nat_rules(&common_bridge, &common_subnet, &upstream_iface) + let upstream_iface = + actor.common.upstream_iface.as_deref().ok_or_else(|| { + eyre!("host-only NAT mode requires an upstream interface") + })?; + + info!( + bridge = %common_bridge, + upstream_iface = upstream_iface, + "host-only NAT uses routed upstream interface rather than bridge master" + ); + + Self::ensure_nat_rules(&common_bridge, &common_subnet, upstream_iface) .wrap_err_with(|| { format!( "failed to ensure nftables NAT rules for bridge {} and upstream {}", @@ -432,6 +522,13 @@ impl Actor for NetworkAgentActor { subnet, gateway, } => { + info!( + bridge = %actor.common.bridge, + gateway = %gateway, + prefix_len = subnet.prefix_len(), + "ensuring bridged mode host address on bridge" + ); + ensure_address( &actor.netlink_handle, bridge.header.index, diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 9023c6c..61c077e 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -106,10 +106,12 @@ impl VMInstance { let vm_config = self.info().await?.config; + debug!(vmid = self.vm_id(), "before_boot hooks invoked"); self.hook_manager .before_boot(self.vm_id(), &vm_config) .await?; + debug!(vmid = self.vm_id(), "boot_vm invoked"); self.conn() .boot_vm() .await @@ -120,6 +122,7 @@ impl VMInstance { let vm_info_postboot = self.info().await?; + debug!(vmid = self.vm_id(), "after_boot hooks invoked"); self.hook_manager .after_boot(self.vm_id(), &vm_info_postboot) .await?; @@ -498,7 +501,7 @@ impl VMInstance { self.vm_id() ))?; - // self.save_config(&config)?; + self.vm_config = Some(transformed_config.clone()); trace!(vm_id = self.vm_id(), "Creating VM via CH API"); self.conn() diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo-agent/src/state/provisioning/hooks/mod.rs index 1b5d217..f19b02c 100644 --- a/odorobo-agent/src/state/provisioning/hooks/mod.rs +++ b/odorobo-agent/src/state/provisioning/hooks/mod.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use cloud_hypervisor_client::models::{VmConfig, VmInfo}; use stable_eyre::Result; +use tracing::info; mod machined; mod networking; @@ -76,16 +77,28 @@ impl HookManager { } pub async fn before_boot(&self, vmid: &str, config: &VmConfig) -> Result<()> { + info!( + vmid = vmid, + hook_count = self.hooks.len(), + "running before_boot hooks" + ); for hook in &self.hooks { hook.before_boot(vmid, config).await?; } + info!(vmid = vmid, "completed before_boot hooks"); Ok(()) } pub async fn after_boot(&self, vmid: &str, config: &VmInfo) -> Result<()> { + info!( + vmid = vmid, + hook_count = self.hooks.len(), + "running after_boot hooks" + ); for hook in &self.hooks { hook.after_boot(vmid, config).await?; } + info!(vmid = vmid, "completed after_boot hooks"); Ok(()) } } diff --git a/odorobo-agent/src/state/provisioning/hooks/networking.rs b/odorobo-agent/src/state/provisioning/hooks/networking.rs index f8d3cec..6ba5f74 100644 --- a/odorobo-agent/src/state/provisioning/hooks/networking.rs +++ b/odorobo-agent/src/state/provisioning/hooks/networking.rs @@ -6,6 +6,7 @@ use stable_eyre::{ Result, eyre::{WrapErr, eyre}, }; +use tracing::info; use ulid::Ulid; use crate::networking::{ @@ -32,6 +33,7 @@ fn tap_names(info: &VmInfo) -> Vec { .as_ref() .map(|nets| { nets.iter() + .filter(|net| net.id.as_deref().is_some_and(|id| id.starts_with("net://"))) .filter_map(|net| net.tap.clone()) .collect::>() }) @@ -40,15 +42,21 @@ fn tap_names(info: &VmInfo) -> Vec { #[async_trait] impl ProvisioningHook for NetworkProvisioningHook { - // CH auto-generates TAP names for each network device and create them if they don't - // already exist, so we are okay with the tap names we get from the VM config + // Only Odorobo-managed `net://` network IDs participate in bridge attach/detach + // handling here. Other network devices are left alone. async fn after_boot(&self, vmid: &str, config: &VmInfo) -> Result<()> { let vmid = Ulid::from_string(vmid) .map_err(|err| eyre!("invalid vmid {vmid}: {err}")) .wrap_err("failed to parse vmid for networking hook")?; let taps = tap_names(config); + info!( + vmid = %vmid, + tap_count = taps.len(), + "networking after_boot hook invoked for Odorobo-managed net:// TAP devices" + ); if taps.is_empty() { + info!(vmid = %vmid, "no TAP devices present after boot, skipping network attach"); return Ok(()); } @@ -57,6 +65,7 @@ impl ProvisioningHook for NetworkProvisioningHook { .wrap_err("failed to look up network actor")?; for tap_name in taps { + info!(vmid = %vmid, tap = %tap_name, "sending AttachTap to network actor"); network_actor .ask(AttachTap { vmid, @@ -76,7 +85,13 @@ impl ProvisioningHook for NetworkProvisioningHook { .wrap_err("failed to parse vmid for networking hook")?; let taps = tap_names(config); + info!( + vmid = %vmid, + tap_count = taps.len(), + "networking before_stop hook invoked for Odorobo-managed net:// TAP devices" + ); if taps.is_empty() { + info!(vmid = %vmid, "no TAP devices present before stop, skipping network detach"); return Ok(()); } @@ -85,6 +100,7 @@ impl ProvisioningHook for NetworkProvisioningHook { .wrap_err("failed to look up network actor")?; for tap_name in taps { + info!(vmid = %vmid, tap = %tap_name, "sending DetachTap to network actor"); network_actor .ask(DetachTap { vmid, diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo-agent/src/state/transform/mod.rs index 5318bec..e979a15 100644 --- a/odorobo-agent/src/state/transform/mod.rs +++ b/odorobo-agent/src/state/transform/mod.rs @@ -12,9 +12,11 @@ pub trait ConfigTransform: Send + Sync { } pub mod console; +pub mod networking; pub mod path_verify; pub mod storage; pub use console::ConsoleTransform; +pub use networking::NetworkTransform; pub use path_verify::PathVerify; use tracing::trace; @@ -58,6 +60,7 @@ impl Default for TransformChain { Self::new() .add(storage::StorageDriverTransformer::default()) .add(ConsoleTransform) + .add(NetworkTransform) .add(PathVerify) } } diff --git a/odorobo-agent/src/state/transform/networking.rs b/odorobo-agent/src/state/transform/networking.rs new file mode 100644 index 0000000..275e372 --- /dev/null +++ b/odorobo-agent/src/state/transform/networking.rs @@ -0,0 +1,195 @@ +use cloud_hypervisor_client::models::{NetConfig, VmConfig}; +use stable_eyre::{ + Result, + eyre::{WrapErr, eyre}, +}; +use tracing::info; + +use super::ConfigTransform; + +/// Rewrites odorobo-local network URI references into deterministic TAP device names. +/// +/// Expected input shape: +/// - `net.id = Some("net://")` +/// +/// Rewritten output: +/// - `net.id = Some("net://")` +/// - `net.tap = Some("vmtap--")` +/// +/// This lets Cloud Hypervisor use a known TAP name instead of relying on runtime +/// auto-generated TAP names, which are not reflected back in VM info reliably, +/// while preserving the Odorobo-local URI marker for later hook logic. +/// +/// Notes: +/// - The TAP name is deterministic per `(vmid, network_id)`. +/// - We intentionally do not create the TAP device here yet; this transformer only +/// mutates the VM config into a host-specific concrete value. +/// - `network_id` is restricted to a conservative character set suitable for Linux +/// interface naming and CH IDs. +#[derive(Debug, Clone)] +pub struct NetworkTransform; + +const NETWORK_URI_PREFIX: &str = "net://"; +const TAP_PREFIX: &str = "vmtap"; +const MAX_IFNAME_LEN: usize = 15; + +impl ConfigTransform for NetworkTransform { + #[tracing::instrument(skip(config))] + fn transform(&self, vmid: &str, config: &mut VmConfig) -> Result<()> { + let Some(nets) = config.net.as_mut() else { + return Ok(()); + }; + + for net in nets.iter_mut() { + rewrite_net_config(vmid, net)?; + } + + Ok(()) + } +} + +fn rewrite_net_config(vmid: &str, net: &mut NetConfig) -> Result<()> { + let Some(id) = net.id.as_deref() else { + return Ok(()); + }; + + let Some(network_id) = id.strip_prefix(NETWORK_URI_PREFIX) else { + return Ok(()); + }; + + validate_network_id(network_id).wrap_err_with(|| format!("invalid network URI id {id}"))?; + + let tap_name = deterministic_tap_name(vmid, network_id); + + info!( + vmid = vmid, + network_id = network_id, + tap = tap_name, + "rewriting network URI to deterministic TAP device" + ); + + net.id = Some(id.to_string()); + net.tap = Some(tap_name); + + Ok(()) +} + +fn validate_network_id(network_id: &str) -> Result<()> { + if network_id.is_empty() { + return Err(eyre!("network id must not be empty")); + } + + if !network_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(eyre!( + "network id must contain only ASCII alphanumeric characters, '-' or '_'" + )); + } + + Ok(()) +} + +fn deterministic_tap_name(vmid: &str, network_id: &str) -> String { + let mut vmid_part = sanitize_component(vmid); + let mut network_part = sanitize_component(network_id); + + // Linux interface names are typically limited to 15 bytes. + // Prefer keeping some of both the VMID and network id rather than truncating + // one side entirely. For VMIDs we keep the suffix rather than the prefix, + // because ULID prefixes are timestamp-derived and less useful for uniqueness. + let separator_len = 2; // two '-' + let fixed_len = TAP_PREFIX.len() + separator_len; + let available = MAX_IFNAME_LEN.saturating_sub(fixed_len); + + if available == 0 { + return TAP_PREFIX[..MAX_IFNAME_LEN.min(TAP_PREFIX.len())].to_string(); + } + + let half = available / 2; + let vmid_budget = half.max(1); + let network_budget = available.saturating_sub(vmid_budget).max(1); + + if vmid_part.len() > vmid_budget { + vmid_part = vmid_part[vmid_part.len() - vmid_budget..].to_string(); + } + if network_part.len() > network_budget { + network_part = network_part[network_part.len() - network_budget..].to_string(); + } + + let mut tap = format!("{TAP_PREFIX}-{vmid_part}-{network_part}"); + if tap.len() > MAX_IFNAME_LEN { + tap.truncate(MAX_IFNAME_LEN); + } + tap +} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use cloud_hypervisor_client::models::VmConfig; + + #[test] + fn rewrites_net_uri_into_tap_and_plain_id() { + let mut config = VmConfig { + net: Some(vec![NetConfig { + id: Some("net://devnet".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + + NetworkTransform + .transform("01KPB9AMVWXC2D1PF4W6E7MHWT", &mut config) + .unwrap(); + + let net = &config.net.as_ref().unwrap()[0]; + assert_eq!(net.id.as_deref(), Some("net://devnet")); + assert_eq!(net.tap.as_deref(), Some("vmtap-7mhwt-vnet")); + } + + #[test] + fn ignores_non_network_uri_ids() { + let mut config = VmConfig { + net: Some(vec![NetConfig { + id: Some("net1".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + + NetworkTransform.transform("vmid", &mut config).unwrap(); + + let net = &config.net.as_ref().unwrap()[0]; + assert_eq!(net.id.as_deref(), Some("net1")); + assert!(net.tap.is_none()); + } + + #[test] + fn rejects_invalid_network_id() { + let mut config = VmConfig { + net: Some(vec![NetConfig { + id: Some("net://bad/id".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + + let err = NetworkTransform.transform("vmid", &mut config).unwrap_err(); + assert!(err.to_string().contains("invalid network URI id")); + } +} From fb39d8e5db7ff3f323cb938ab43d38f54629b0a1 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 08:09:25 +0700 Subject: [PATCH 112/189] add dependencies --- .github/workflows/build_x86.yml | 3 ++- .github/workflows/clippy.yml | 11 +++++------ README.md | 3 +++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 88cde71..6c6c184 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -19,7 +19,8 @@ jobs: - uses: rui314/setup-mold@v1 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 - + - name: Install dependencies + run: apt-get install -y libmnl-dev libnftnl-dev clang - name: Build run: cargo build --verbose --release - name: Upload artifact - agent diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 9da9138..fed78a8 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -13,8 +13,7 @@ on: push: pull_request: schedule: - - cron: '39 6 * * 6' - + - cron: "39 6 * * 6" env: CARGO_TERM_COLOR: always @@ -33,6 +32,9 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Install dependencies + run: apt-get install -y libmnl-dev libnftnl-dev clang + - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 with: @@ -41,18 +43,15 @@ jobs: components: clippy override: true - - uses: rui314/setup-mold@v1 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 - - name: Install required cargo run: cargo install clippy-sarif sarif-fmt - name: Run rust-clippy - run: - cargo clippy + run: cargo clippy --all-features --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt continue-on-error: true diff --git a/README.md b/README.md index 0dce614..218da73 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ Odorobo Agent is meant to be run as a system agent on each bare-metal node (or a Build the Agent binary with `cargo build --release` and run it on the host machine. The Agent will listen for commands from the Gateway to create, manage, and delete VMs. ```bash +# Install dependencies (fedora) +sudo dnf in -y libmnl-devel libnftnl-devel clang-devel + # Build the Agent cargo build --release # Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus From de3ccd516278b7fab092512351afdd2387b1f452 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 08:28:07 +0700 Subject: [PATCH 113/189] switch to nftables crate libnftnl and mnl is kinda overkill for this, so we're falling back to just wrapping nft directly. netaavark also does this so it makes sense for us too --- .github/workflows/build_x86.yml | 53 ++++--- .github/workflows/clippy.yml | 2 +- Cargo.lock | 105 +++++++------- Containerfile | 4 +- README.md | 2 +- odorobo-agent/Cargo.toml | 4 +- odorobo-agent/src/networking/actor.rs | 192 +++++++++++++------------- 7 files changed, 184 insertions(+), 178 deletions(-) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 6c6c184..627afeb 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -11,33 +11,32 @@ env: jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: rui314/setup-mold@v1 - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - name: Install dependencies - run: apt-get install -y libmnl-dev libnftnl-dev clang - - name: Build - run: cargo build --verbose --release - - name: Upload artifact - agent - uses: actions/upload-artifact@v7 - with: - archive: false - name: odorobo-agent-x86 - path: | - target/release/odorobo-agent - - - name: Upload artifact - odoroboctl - uses: actions/upload-artifact@v7 - with: - archive: false - name: odoroboctl-x86 - path: | - target/release/odoroboctl - - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - uses: rui314/setup-mold@v1 + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + - name: Install dependencies + run: apt-get install -y clang + - name: Build + run: cargo build --verbose --release + - name: Upload artifact - agent + uses: actions/upload-artifact@v7 + with: + archive: false + name: odorobo-agent-x86 + path: | + target/release/odorobo-agent + + - name: Upload artifact - odoroboctl + uses: actions/upload-artifact@v7 + with: + archive: false + name: odoroboctl-x86 + path: | + target/release/odoroboctl + + - name: Run tests + run: cargo test --verbose diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index fed78a8..78afa77 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v4 - name: Install dependencies - run: apt-get install -y libmnl-dev libnftnl-dev clang + run: apt-get install -y clang - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 diff --git a/Cargo.lock b/Cargo.lock index 1676414..5ff62ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2709,27 +2709,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mnl" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ab362254419b09757228dc96e0d834e75392c52870d759df2274753007065" -dependencies = [ - "libc", - "log", - "mnl-sys", -] - -[[package]] -name = "mnl-sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f3c607af12ed53f3cade8ee7adeced5533800abc0b07f5b6bdc7081d1e0d4c" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "moka" version = "0.12.15" @@ -2864,27 +2843,19 @@ dependencies = [ ] [[package]] -name = "nftnl" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a530f904e31675dc9d2e3a5c166a844dffc3dad644205fe5abb638f4e9e75d9b" -dependencies = [ - "bitflags", - "log", - "nftnl-sys", - "nix", -] - -[[package]] -name = "nftnl-sys" -version = "0.6.4" +name = "nftables" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffa28b4c81eea6a8a4fd454634d425e1a559250a50a926de4517dd9607fe9a4" +checksum = "3c57e7343eed9e9330e084eef12651b15be3c8ed7825915a0ffa33736b852bed" dependencies = [ - "cfg-if", - "libc", - "mnl-sys", - "pkg-config", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_path_to_error", + "strum", + "strum_macros", + "thiserror 2.0.18", + "tokio", ] [[package]] @@ -3016,8 +2987,7 @@ dependencies = [ "kameo", "libc", "libp2p", - "mnl", - "nftnl", + "nftables", "odorobo-shared", "random-port", "rtnetlink", @@ -3267,12 +3237,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "polling" version = "3.11.0" @@ -3915,7 +3879,20 @@ dependencies = [ "dyn-clone", "indexmap", "ref-cast", - "schemars_derive", + "schemars_derive 0.9.0", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -3932,6 +3909,18 @@ dependencies = [ "syn", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4225,6 +4214,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Containerfile b/Containerfile index 6f012a2..40d75dc 100644 --- a/Containerfile +++ b/Containerfile @@ -4,9 +4,7 @@ RUN apk add \ gcc \ musl-dev \ rustup \ - zig \ - libnftnl-dev \ - libmnl-dev + zig RUN rustup-init \ --profile minimal \ --target riscv64a23-unknown-linux-gnu \ diff --git a/README.md b/README.md index 218da73..f53d325 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Build the Agent binary with `cargo build --release` and run it on the host machi ```bash # Install dependencies (fedora) -sudo dnf in -y libmnl-devel libnftnl-devel clang-devel +sudo dnf in -y clang-devel nftables # Build the Agent cargo build --release diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 40ccce9..3179524 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -47,6 +47,4 @@ odorobo-shared = { workspace = true } ulid = { workspace = true } ipnet = { workspace = true } rtnetlink = "0.20.0" -nftnl = { version = "0.9.1", features = ["nftnl-1-1-3"] } -mnl = "0.3.1" - +nftables = { version = "0.6.3", features = ["tokio"] } diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 63aa338..0893d45 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -3,14 +3,17 @@ use crate::networking::messages::{AttachTap, DetachTap}; use futures_util::{StreamExt, TryStreamExt}; use kameo::{message::Context, prelude::*}; -use nftnl::{ - Batch, FinalizedBatch, Hook, MsgType, ProtoFamily, Rule, Table, expr::InterfaceName, nft_expr, +use nftables::{ + batch::Batch as NftBatch, + expr::{Expression, Meta, MetaKey, NamedExpression}, + helper::{apply_ruleset, get_current_ruleset}, + schema::{Chain as NftChain, NfListObject, NfObject, Rule as NftRule, Table as NftTable}, + stmt::{Match as NftMatch, Operator, Statement}, + types::{NfChainPolicy, NfChainType, NfFamily, NfHook}, }; use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; use stable_eyre::Report; use stable_eyre::eyre::{Context as EyreContext, eyre}; -use std::ffi::CString; -use std::process::Command; use tracing::info; pub struct DhcpActor { @@ -165,75 +168,50 @@ impl NetworkAgentActor { .wrap_err_with(|| format!("failed to query link {}", link_name)) } - fn send_nft_batch(batch: &FinalizedBatch) -> Result<(), Report> { - let socket = mnl::Socket::new(mnl::Bus::Netfilter) - .wrap_err("failed to create netfilter netlink socket")?; - let portid = socket.portid(); - - info!("sending nftables batch to netfilter"); - - socket - .send_all(batch) - .wrap_err("failed to send nftables batch to netfilter")?; - - let mut buffer = vec![0; nftnl::nft_nlmsg_maxsize() as usize]; - let mut expected_seqs = batch.sequence_numbers(); - - while !expected_seqs.is_empty() { - for message in socket - .recv(&mut buffer[..]) - .wrap_err("failed to receive nftables netlink acknowledgement")? - { - let message = message.wrap_err("failed to decode nft ack message")?; - let expected_seq = expected_seqs - .next() - .ok_or_else(|| eyre!("received unexpected nftables acknowledgement"))?; - - mnl::cb_run(message, expected_seq, portid) - .wrap_err("nftables batch acknowledgement failed")?; + fn nft_table_exists(objects: &[NfObject<'_>], table: &str) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Table(existing)) => { + existing.family == NfFamily::IP && existing.name.as_ref() == table } - } - - Ok(()) - } - - // use the nft CLI instead because doing full introspection is kind of a pain - fn nft_table_exists(table: &str) -> Result { - let output = Command::new("nft") - .args(["list", "table", "ip", table]) - .output() - .wrap_err_with(|| format!("failed to query nft table ip {table}"))?; - - Ok(output.status.success()) + _ => false, + }) } - fn nft_chain_exists(table: &str, chain: &str) -> Result { - let output = Command::new("nft") - .args(["list", "chain", "ip", table, chain]) - .output() - .wrap_err_with(|| format!("failed to query nft chain ip {table} {chain}"))?; - - Ok(output.status.success()) + fn nft_chain_exists(objects: &[NfObject<'_>], table: &str, chain: &str) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Chain(existing)) => { + existing.family == NfFamily::IP + && existing.table.as_ref() == table + && existing.name.as_ref() == chain + } + _ => false, + }) } fn nft_postrouting_masquerade_exists( + objects: &[NfObject<'_>], table: &str, chain: &str, upstream_iface: &str, - ) -> Result { - let output = Command::new("nft") - .args(["list", "chain", "ip", table, chain]) - .output() - .wrap_err_with(|| format!("failed to inspect nft chain ip {table} {chain}"))?; - - if !output.status.success() { - return Ok(false); - } - - let stdout = String::from_utf8(output.stdout) - .wrap_err_with(|| format!("failed to decode nft output for ip {table} {chain}"))?; - - Ok(stdout.contains(&format!("oifname \"{upstream_iface}\" masquerade"))) + ) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Rule(existing)) => { + existing.family == NfFamily::IP + && existing.table.as_ref() == table + && existing.chain.as_ref() == chain + && matches!( + existing.expr.as_ref(), + [Statement::Match(NftMatch { left, right, op }), Statement::Masquerade(_)] + if *op == Operator::EQ + && matches!( + left, + Expression::Named(NamedExpression::Meta(Meta { key: MetaKey::Oifname })) + ) + && matches!(right, Expression::String(iface) if iface.as_ref() == upstream_iface) + ) + } + _ => false, + }) } /// Ensures the host-only NAT masquerade rule exists for the configured @@ -289,16 +267,19 @@ impl NetworkAgentActor { "ensuring host-only NAT masquerade rule exists" ); - let table_exists = Self::nft_table_exists(TABLE_NAME)?; - let chain_exists = if table_exists { - Self::nft_chain_exists(TABLE_NAME, CHAIN_NAME)? - } else { - false - }; + let ruleset = get_current_ruleset().wrap_err("failed to fetch current nftables ruleset")?; + let objects = ruleset.objects.as_ref(); + + let table_exists = Self::nft_table_exists(objects, TABLE_NAME); + let chain_exists = Self::nft_chain_exists(objects, TABLE_NAME, CHAIN_NAME); + let rule_exists = Self::nft_postrouting_masquerade_exists( + objects, + TABLE_NAME, + CHAIN_NAME, + upstream_iface, + ); - if chain_exists - && Self::nft_postrouting_masquerade_exists(TABLE_NAME, CHAIN_NAME, upstream_iface)? - { + if rule_exists { info!( table = TABLE_NAME, chain = CHAIN_NAME, @@ -308,39 +289,62 @@ impl NetworkAgentActor { return Ok(()); } - let table = Table::new(c"odorobo", ProtoFamily::Ipv4); + let mut batch = NftBatch::new(); - let mut postrouting_chain = nftnl::Chain::new(c"guest_nat", &table); - postrouting_chain.set_type(nftnl::ChainType::Nat); - postrouting_chain.set_hook(Hook::PostRouting, 100); - - let mut batch = Batch::new(); if !table_exists { info!(table = TABLE_NAME, "creating odorobo nftables table"); - batch.add(&table, MsgType::Add); + batch.add(NfListObject::Table(NftTable { + family: NfFamily::IP, + name: TABLE_NAME.into(), + handle: None, + })); } + if !chain_exists { info!( table = TABLE_NAME, chain = CHAIN_NAME, "creating odorobo nftables chain" ); - batch.add(&postrouting_chain, MsgType::Add); + batch.add(NfListObject::Chain(NftChain { + family: NfFamily::IP, + table: TABLE_NAME.into(), + name: CHAIN_NAME.into(), + newname: None, + handle: None, + _type: Some(NfChainType::NAT), + hook: Some(NfHook::Postrouting), + prio: Some(100), + dev: None, + policy: Some(NfChainPolicy::Accept), + })); } - let mut postrouting_rule = Rule::new(&postrouting_chain); - let upstream_iface = InterfaceName::Exact( - CString::new(upstream_iface) - .wrap_err("upstream interface name contained interior NUL")?, - ); - postrouting_rule.add_expr(&nft_expr!(meta oifname)); - postrouting_rule.add_expr(&nft_expr!(cmp == &upstream_iface)); - postrouting_rule.add_expr(&nft_expr!(masquerade)); - batch.add(&postrouting_rule, MsgType::Add); - - let finalized = batch.finalize(); - Self::send_nft_batch(&finalized).wrap_err_with(|| { - format!("failed to apply nftables postrouting masquerade for {upstream_iface:?}") + batch.add(NfListObject::Rule(NftRule { + family: NfFamily::IP, + table: TABLE_NAME.into(), + chain: CHAIN_NAME.into(), + expr: vec![ + Statement::Match(NftMatch { + left: Expression::Named(NamedExpression::Meta(Meta { + key: MetaKey::Oifname, + })), + right: Expression::String(upstream_iface.into()), + op: Operator::EQ, + }), + Statement::Masquerade(None), + ] + .into(), + handle: None, + index: None, + comment: Some("odorobo host-only NAT".into()), + })); + + let batch = batch.to_nftables(); + apply_ruleset(&batch).wrap_err_with(|| { + format!( + "failed to apply nftables postrouting masquerade for upstream interface {upstream_iface}" + ) })?; Ok(()) From bd21d74058db94f2edca13af53e0ed83a9030909 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 08:41:20 +0700 Subject: [PATCH 114/189] don't install deps We don't need to install clang because it's already in the CI image --- .github/workflows/build_x86.yml | 3 +-- .github/workflows/clippy.yml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 627afeb..e7b582d 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -18,8 +18,7 @@ jobs: - uses: rui314/setup-mold@v1 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 - - name: Install dependencies - run: apt-get install -y clang + - name: Build run: cargo build --verbose --release - name: Upload artifact - agent diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 78afa77..772664f 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -32,9 +32,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Install dependencies - run: apt-get install -y clang - - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 with: From a3eaff9e0d7fa164c1757554bd312c42ab936079 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 08:50:55 +0700 Subject: [PATCH 115/189] fix vmid test --- odorobo-agent/src/state/transform/networking.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odorobo-agent/src/state/transform/networking.rs b/odorobo-agent/src/state/transform/networking.rs index 275e372..83a6dc6 100644 --- a/odorobo-agent/src/state/transform/networking.rs +++ b/odorobo-agent/src/state/transform/networking.rs @@ -159,7 +159,7 @@ mod tests { let net = &config.net.as_ref().unwrap()[0]; assert_eq!(net.id.as_deref(), Some("net://devnet")); - assert_eq!(net.tap.as_deref(), Some("vmtap-7mhwt-vnet")); + assert_eq!(net.tap.as_deref(), Some("vmtap-mhwt-vnet")); } #[test] From 2ee8ea87e7222b98805169c3c05ab612012c2dd4 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 09:03:13 +0700 Subject: [PATCH 116/189] fix clippy lints --- odorobo-agent/src/actor/mod.rs | 1 - odorobo-agent/src/networking/actor.rs | 36 +++++++++++++-------------- odorobo-agent/src/state/instance.rs | 1 - 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index f7ddfce..ec81792 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -7,7 +7,6 @@ use odorobo_shared::{ messages::{Ping, Pong, debug::PanicAgent, vm::*}, utils::vm_actor_id, }; -use rtnetlink::packet_route::route::RouteMessage; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::ops::ControlFlow; diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 0893d45..75db627 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -214,23 +214,6 @@ impl NetworkAgentActor { }) } - /// Ensures the host-only NAT masquerade rule exists for the configured - /// upstream interface. - /// - /// This is intentionally IPv4-only for now. - /// - /// The current host-only networking model is built around IPv4 guest - /// addressing and an IPv4 bridge gateway: - /// - guest subnet config uses `Ipv4Net` - /// - bridge gateway config uses `Ipv4Addr` - /// - bridge address assignment is currently IPv4-only - /// - /// Although nftables can match by interface in `inet` tables, switching - /// this masquerade rule to dual-stack would implicitly opt us into IPv6 - /// NAT/NAT66 policy as well. That is a larger design decision than this - /// hook should make on its own. Until odorobo has an intentional IPv6 - /// guest-networking story, we keep host-only NAT scoped to IPv4. - /// // todo: IPv6, refer to libvirt's impl: // ```nft // table ip6 libvirt_network { @@ -255,6 +238,23 @@ impl NetworkAgentActor { // } // } // ``` + /// Ensures the host-only NAT masquerade rule exists for the configured + /// upstream interface. + /// + /// This is intentionally IPv4-only for now. + /// + /// The current host-only networking model is built around IPv4 guest + /// addressing and an IPv4 bridge gateway: + /// - guest subnet config uses `Ipv4Net` + /// - bridge gateway config uses `Ipv4Addr` + /// - bridge address assignment is currently IPv4-only + /// + /// Although nftables can match by interface in `inet` tables, switching + /// this masquerade rule to dual-stack would implicitly opt us into IPv6 + /// NAT/NAT66 policy as well. That is a larger design decision than this + /// hook should make on its own. Until odorobo has an intentional IPv6 + /// guest-networking story, we keep host-only NAT scoped to IPv4. + /// fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { const TABLE_NAME: &str = "odorobo"; @@ -393,7 +393,7 @@ impl Actor for NetworkAgentActor { let bridge = match bridge_lookup { Some(Ok(bridge)) => bridge, - Some(Err(err)) if matches!(err, NetlinkError::NetlinkError(ref nl_err) if nl_err.raw_code() == -libc::ENODEV) => + Some(Err(NetlinkError::NetlinkError(ref nl_err))) if nl_err.raw_code() == -libc::ENODEV => { info!(bridge = %common.bridge, "bridge not found during lookup, creating it"); let new_bridge = LinkBridge::new(&common.bridge).up().build(); diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index 61c077e..da2e6ef 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -198,7 +198,6 @@ impl VMInstance { let receive_migration_data = models::ReceiveMigrationData { receiver_url: receiver_uri.clone(), - ..Default::default() }; let vm_id = self.vm_id().to_string(); From d01691f3e038b0d3c59bdfe70f23d6e9512e092b Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 09:03:39 +0700 Subject: [PATCH 117/189] oops --- odorobo-agent/src/networking/actor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 75db627..402de8e 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -254,7 +254,6 @@ impl NetworkAgentActor { /// NAT/NAT66 policy as well. That is a larger design decision than this /// hook should make on its own. Until odorobo has an intentional IPv6 /// guest-networking story, we keep host-only NAT scoped to IPv4. - /// fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { const TABLE_NAME: &str = "odorobo"; From 9b51599a9cc01715c7e69065c2ddd1d6e8c84c84 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Fri, 17 Apr 2026 09:04:28 +0700 Subject: [PATCH 118/189] fix clippy lint again --- odorobo-agent/src/networking/actor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo-agent/src/networking/actor.rs index 402de8e..1080e45 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo-agent/src/networking/actor.rs @@ -254,7 +254,6 @@ impl NetworkAgentActor { /// NAT/NAT66 policy as well. That is a larger design decision than this /// hook should make on its own. Until odorobo has an intentional IPv6 /// guest-networking story, we keep host-only NAT scoped to IPv4. - fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { const TABLE_NAME: &str = "odorobo"; const CHAIN_NAME: &str = "guest_nat"; From b649b85331fb65471d1e73f4c84c238aa4afd087 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 18 Apr 2026 04:20:57 +0700 Subject: [PATCH 119/189] document cloud-init --- .gitignore | 2 ++ docs/provisioning.md | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 docs/provisioning.md diff --git a/.gitignore b/.gitignore index cf495b1..69ed440 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dev # MSVC Windows builds of rustc generate these, which store debugging information *.pdb +*.yaml + # Generated by cargo mutants # Contains mutation testing data **/mutants.out*/ diff --git a/docs/provisioning.md b/docs/provisioning.md new file mode 100644 index 0000000..f30aee6 --- /dev/null +++ b/docs/provisioning.md @@ -0,0 +1,21 @@ +# Provisioning + +## Cloud Hypervisor + +To provision cloud-init-compatible images with Cloud Hypervisor, set the serial number in the VmConfig to a +cloud-init [line config](https://docs.cloud-init.io/en/latest/reference/datasources/nocloud.html#line-configuration-in-detail). + + +```json +{ + ... + "platform": { + "serial_number": "ds=nocloud,..." + }, +} +``` + +You may set anything in there, or have a metadata service that seeds this configuration. + +> [!NOTE] +> As of April 2026, this seems to only work on EDK2 firmware, rust-hypervisor-firmware does not seem to export DMI data From 7aeea399490a3724ecc45e946e6efd79ca92d32b Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 18 Apr 2026 06:41:30 +0700 Subject: [PATCH 120/189] setup devcontainers and rust version --- .devcontainer/Dockerfile | 15 +++++++++++++++ .devcontainer/devcontainer.json | 34 +++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b512bc9 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM ghcr.io/ultramarine-linux/ultramarine:43 + +# Install dependencies +RUN dnf do -y --action=install \ + rustup \ + clang-devel \ + cmake \ + ninja \ + just \ + git \ + zig \ + cloud-hypervisor-nightly + +RUN mkdir -p /run/odorobo/vms +RUN chmod -R 1777 /run/odorobo \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..95000ed --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile +{ + "features": { + "ghcr.io/devcontainers/features/rust:1": {}, + }, + "name": "Existing Dockerfile", + "build": { + // Sets the run context to one level up instead of the .devcontainer folder. + "context": ".", + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerfile": "Dockerfile" + }, + // required for netlink and other privileged operations + "privileged": true, + "capAdd": [ + "NET_ADMIN" + ], + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment the next line to run commands after the container is created. + // "postCreateCommand": "cat /etc/os-release", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} diff --git a/Cargo.toml b/Cargo.toml index 9bff8ea..5330d21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["odorobo-agent", "odoroboctl", "odorobo-manager", "odorobo-shared"] - +rust-version = "1.95.0" [workspace.dependencies] stable-eyre = "0.2.2" tracing = "0.1" From d7f17a6d81599fc9332adeac46da10b5c6920f3b Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Sat, 18 Apr 2026 06:49:12 +0700 Subject: [PATCH 121/189] rename devcontainer --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 95000ed..2bfa72d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/rust:1": {}, }, - "name": "Existing Dockerfile", + "name": "Dev", "build": { // Sets the run context to one level up instead of the .devcontainer folder. "context": ".", From 6785cbff12794a3cd4389e6d44fe3ecee0950220 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 20 Apr 2026 01:16:14 +0700 Subject: [PATCH 122/189] fix: handle case when child dies before actor Add a handler that `take()`s the child process, waiting for unexpected deaths to clean up and kill processes --- Cargo.toml | 1 + odorobo-agent/src/state/instance.rs | 8 +++ .../src/state/provisioning/actor/mod.rs | 51 +++++++++++-------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5330d21..d86b156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = ["odorobo-agent", "odoroboctl", "odorobo-manager", "odorobo-shared"] +[workspace.package] rust-version = "1.95.0" [workspace.dependencies] stable-eyre = "0.2.2" diff --git a/odorobo-agent/src/state/instance.rs b/odorobo-agent/src/state/instance.rs index da2e6ef..ecbf347 100644 --- a/odorobo-agent/src/state/instance.rs +++ b/odorobo-agent/src/state/instance.rs @@ -93,6 +93,14 @@ impl VMInstance { } } + + /// Takes the child process out of this instance, transferring ownership to the caller. + /// Useful for watching the process lifecycle externally (e.g. in an actor watcher task). + /// After calling this, `destroy()` will skip the child-kill step. + pub fn take_child_process(&mut self) -> Option { + self.child_process.take() + } + /// Get a VM instance by its ID through the filesystem database /// /// Not reliable as of 0.2 diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index 9c63f0d..cc7df53 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -8,7 +8,7 @@ use odorobo_shared::messages::vm::{ use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use tokio::task::JoinHandle; -use tracing::{error, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; /* use std::process::Command; @@ -43,27 +43,34 @@ impl Actor for VMActor { type Error = Report; #[tracing::instrument(skip_all)] - async fn on_start((vmid, vm_config): Self::Args, _actor_ref: ActorRef) -> Result { - // let ch_sock_path = VMInstance::runtime_dir_for(&vmid.to_string()).join("ch.sock"); - - // // no transform chain - - // tracing::warn!("no-op"); - // // spawn CH instance - // // this probably is not an ideal way to do this, but we want a minimal thing - // // so let's spawn CH as a child - // // - // // ...or we go back to that systemd way - - // // ownership quirk - // // let value = ch_sock_path.clone(); - // let ch_process = tokio::process::Command::new("cloud-hypervisor") - // .arg("--api-socket") - // .arg(&ch_sock_path) - // .spawn()?; - // tokio::spawn(async move { - - let vminstance = VMInstance::spawn(&vmid.to_string(), vm_config, None).await?; + async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { + let mut vminstance = VMInstance::spawn(&vmid.to_string(), vm_config, None).await?; + + // Take the child process out so we can watch for unexpected death. + // destroy() handles a missing child_process gracefully. + if let Some(mut child_process) = vminstance.take_child_process() { + let actor_ref = actor_ref.clone(); + tokio::spawn(async move { + debug!(%vmid, "watching child process to handle actor cleanup"); + match child_process.wait().await { + Ok(status) => { + if !status.success() { + error!(%vmid, ?status, "child process exited unexpectedly, killing actor"); + let _ = actor_ref.kill(); + } else { + warn!(%vmid, "child process exited outside of actor teardown"); + } + let _ = actor_ref.stop_gracefully().await; + } + Err(err) => { + error!(%vmid, ?err, "failed to wait on child process, killing actor"); + actor_ref.kill(); + } + }; + }); + } else { + warn!(%vmid, "VMInstance has no child process to watch"); + } Ok(Self { vmid, From 4b281bd230e1b24c008f421288372d3b35d1b4fb Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Mon, 20 Apr 2026 01:18:58 +0700 Subject: [PATCH 123/189] fix: handle graceful death I was doing it outside of the branched and realized this is probably wrong. This fixes it --- odorobo-agent/src/state/provisioning/actor/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo-agent/src/state/provisioning/actor/mod.rs index cc7df53..36493d4 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo-agent/src/state/provisioning/actor/mod.rs @@ -59,8 +59,8 @@ impl Actor for VMActor { let _ = actor_ref.kill(); } else { warn!(%vmid, "child process exited outside of actor teardown"); + let _ = actor_ref.stop_gracefully().await; } - let _ = actor_ref.stop_gracefully().await; } Err(err) => { error!(%vmid, ?err, "failed to wait on child process, killing actor"); From f63b7f2e50a4e347f74eae38b1af212f8c9c3a4c Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Tue, 21 Apr 2026 06:27:36 +0700 Subject: [PATCH 124/189] add storage URI type, make them transparent --- Cargo.lock | 1 + Cargo.toml | 1 + odorobo-manager/Cargo.toml | 1 + odorobo-manager/src/api/types.rs | 29 ++++++++++++++++++++++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5ff62ab..6a024ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3032,6 +3032,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ulid", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d86b156..f3e06a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ odorobo-manager = { path = "odorobo-manager" } dotenvy = "0.15" clap = { version = "4", features = ["derive", "env"] } ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} +url = { version = "2.3", features = ["serde"] } # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. # also look in .cargo/config.toml for info on microarchitecture optimizations. diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index 7a49bc4..9f92e2a 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -33,6 +33,7 @@ ulid = { workspace = true } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } reqwest = { version = "0.13", features = ["json"] } ahash = { version = "0.8.12", features = ["serde"] } +url = { workspace = true } # utoipa = { version = "5.4.0", features = ["uuid"] } odorobo-shared = { workspace = true } stable-eyre = { workspace = true } diff --git a/odorobo-manager/src/api/types.rs b/odorobo-manager/src/api/types.rs index 0ed0b47..a0f037a 100644 --- a/odorobo-manager/src/api/types.rs +++ b/odorobo-manager/src/api/types.rs @@ -36,13 +36,31 @@ mod opt_bytesize_as_u64 { // Newtype so aide can generate a path parameter schema for Ulid. /// VM ID, in the format of ULID +#[repr(transparent)] #[derive(Serialize, Deserialize, Debug, JsonSchema, OperationIo, Default, Clone)] pub struct VmId(#[schemars(with = "String")] pub Ulid); /// Volume ID, in the format of ULID +#[repr(transparent)] #[derive(Serialize, Deserialize, Debug, JsonSchema, OperationIo, Default, Clone)] pub struct VolumeId(#[schemars(with = "String")] pub Ulid); +/// A URI pointing to the volume's location, e.g an iSCSI URL in `iscsi-inq` format, a local file, or an RBD image. +/// +/// examples: +/// - `iscsi://[[%]@][:]//` +/// - `file:///path/to/volume.img` +/// - `rbd:///` +#[repr(transparent)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, OperationIo, Clone)] +pub struct StorageUri(#[schemars(with = "String")] pub url::Url); + +impl Default for StorageUri { + fn default() -> Self { + StorageUri(url::Url::parse("file:///tmp").unwrap()) + } +} + #[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct CreateVMRequest { /// Data of the VM to create @@ -51,6 +69,7 @@ pub struct CreateVMRequest { pub boot: bool, } + /// An internal, debug-only request for creating a VM. /// /// please don't use this in production, this is for debugging @@ -148,8 +167,16 @@ pub struct Volume { #[schemars(with = "u64")] #[serde(with = "bytesize_as_u64")] pub size: ByteSize, + + /// A URI pointing to the volume's location, e.g an iSCSI URL in `iscsi-inq` format, a local file, or an RBD image. + /// + /// examples: + /// - `iscsi://[[%]@][:]//` + /// - `file:///path/to/volume.img` + /// - `rbd:///` + /// + pub uri: StorageUri, } - // for now pub type CreateVolumeRequest = Volume; From 3b20aaaa1936b8a7bf5377ffefc2bcad6b54c7ca Mon Sep 17 00:00:00 2001 From: Hero Date: Fri, 17 Apr 2026 13:21:59 -0400 Subject: [PATCH 125/189] very mildy start working on this. got distracted with lunch in the middle of starting. Signed-off-by: Hero --- odorobo-manager/src/actors/scheduler_actor.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 68f67c3..198560d 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -12,7 +12,7 @@ use tracing::{info, warn}; #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actor: Option>, + pub agent_actors: Vec>, // this might need to be a hashmap or have lookup tables or something. not sure. } impl SchedulerActor { @@ -51,6 +51,12 @@ impl SchedulerActor { self.agent_actor = Some(new_agent.clone()); Ok(new_agent) } + + async fn update_agents( + &mut self + ) -> Result<(), Report> { + // get all agents via lookup_all and update the list that is held internally. + } } impl Actor for SchedulerActor { @@ -62,6 +68,7 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); + let agent_actor = Self::lookup_agent(&actor_ref).await?; let ping_actor = agent_actor.clone(); @@ -77,7 +84,7 @@ impl Actor for SchedulerActor { }); Ok(Self { - agent_actor: Some(agent_actor), + agent_actors: Some(agent_actor), }) } From 56e7365eb2414a233c588094726cc529bac61194 Mon Sep 17 00:00:00 2001 From: Hero Date: Sat, 18 Apr 2026 00:12:44 -0400 Subject: [PATCH 126/189] more work on multi agent scheduling. we can probably keep track of agents now but we still need something similar for vms. --- Cargo.lock | 1 + odorobo-agent/Cargo.toml | 2 +- odorobo-agent/src/actor/mod.rs | 20 +- odorobo-manager/src/actors/scheduler_actor.rs | 244 ++++++++++++------ odorobo-shared/Cargo.toml | 3 +- .../messages/{server_status.rs => agent.rs} | 9 +- odorobo-shared/src/messages/mod.rs | 2 +- 7 files changed, 197 insertions(+), 84 deletions(-) rename odorobo-shared/src/messages/{server_status.rs => agent.rs} (56%) diff --git a/Cargo.lock b/Cargo.lock index 6a024ae..943b1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3039,6 +3039,7 @@ dependencies = [ name = "odorobo-shared" version = "0.1.0" dependencies = [ + "bytesize", "cloud-hypervisor-client", "kameo", "libp2p", diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 3179524..77b9170 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -42,7 +42,7 @@ kameo = { workspace = true } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } ahash = { workspace = true } sysinfo = "0.38.4" -bytesize = "2.3.1" +bytesize = { workspace = true } odorobo-shared = { workspace = true } ulid = { workspace = true } ipnet = { workspace = true } diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index ec81792..e3722b5 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -4,7 +4,7 @@ use bytesize::ByteSize; use ipnet::Ipv4Net; use kameo::prelude::*; use odorobo_shared::{ - messages::{Ping, Pong, debug::PanicAgent, vm::*}, + messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, utils::vm_actor_id, }; use serde::{Deserialize, Serialize}; @@ -397,6 +397,24 @@ impl Message for AgentActor { } } +#[remote_message] +impl Message for AgentActor { + type Reply = AgentStatus; + + async fn handle( + &mut self, + _msg: GetAgentStatus, + _ctx: &mut Context, + ) -> Self::Reply { + + AgentStatus { + hostname: self.config.hostname.clone(), + vcpus: self.vcpus, + ram: self.memory + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 198560d..b6df9f7 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -1,61 +1,158 @@ use std::ops::ControlFlow; +use std::sync::Arc; use std::time::Duration; +use ahash::AHashMap; use kameo::prelude::*; +use libp2p::futures::TryStreamExt; use odorobo_agent::actor::AgentActor; use odorobo_agent::state::provisioning::actor::VMActor; use odorobo_shared::messages::vm::*; +use odorobo_shared::messages::agent::*; use odorobo_shared::messages::{Ping, Pong}; use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; -use tracing::{info, warn}; +use tokio::sync::Mutex; +use tracing::{info, warn, trace, debug}; +use ulid::Ulid; + +// this arguably should be renamed or something, but I wasn't sure what data to use here. +pub struct AgentActorDataCache { + pub actor_ref: RemoteActorRef, + pub keepalive_task: Option>, + pub metadata: AgentStatus, + +} + +// this arguably should be renamed or something, but I wasn't sure what data to use here. +pub struct VMActorDataCache { + pub vm_actor_ref: RemoteActorRef, + // task that constantly looks up every agent and updates status for it + pub keepalive_task: Option>, +} #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actors: Vec>, // this might need to be a hashmap or have lookup tables or something. not sure. + pub agent_actors: Arc>>, + pub vms: AHashMap, + pub keepalive_task: Option>, } impl SchedulerActor { - async fn lookup_agent( - actor_ref: &ActorRef, - ) -> Result, Report> { - loop { - let agent_actor_option = RemoteActorRef::::lookup("agent").await?; + // async fn lookup_agent( + // &self, + // actor_ref: &ActorRef, + // ) -> Result, Report> { - let Some(agent_actor) = agent_actor_option else { - warn!("No agent actor currently registered, retrying lookup"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - continue; - }; + // let agent_actor_option = RemoteActorRef::::lookup("agent").await?; - let agent_actor_peer_id = *agent_actor.id().peer_id().unwrap(); - info!("Using agent actor peer id: {agent_actor_peer_id}"); + // let Some(agent_actor) = agent_actor_option else { + // warn!("No agent actor currently registered, retrying lookup"); + // tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // return self.lookup_agent(actor_ref).await; + // }; - // remotely link actor, on link death it will be automatically unlinked - info!("Linking agent actor: {agent_actor_peer_id}"); - actor_ref.link_remote(&agent_actor).await?; + // let agent_actor_peer_id = *agent_actor.id().peer_id().unwrap(); + // info!("Using agent actor peer id: {agent_actor_peer_id}"); - return Ok(agent_actor); - } + // // remotely link actor, on link death it will be automatically unlinked + // info!("Linking agent actor: {agent_actor_peer_id}"); + // actor_ref.link_remote(&agent_actor).await?; + + // return Ok(agent_actor); + + // } + + async fn lookup_by_actor_id( + &mut self, + actor_id: &ActorId, + ) -> Option> { + self.agent_actors.lock().await.get(actor_id).map(|data| data.actor_ref.clone()) } - async fn ensure_agent( + async fn lookup_by_hostname( &mut self, - actor_ref: &ActorRef, - ) -> Result, Report> { - if let Some(agent_actor) = &self.agent_actor { - return Ok(agent_actor.clone()); - } + hostname: &str, + ) -> Option> { + self.agent_actors.lock().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) + } + + async fn schedule_agent( + &mut self, + msg: &CreateVM + ) -> RemoteActorRef { + + let agents: Vec<&AgentActorDataCache> = self.agent_actors.lock().await.values().collect(); + + // warn!("randomly selecting agent"); + let agent_index = 0; // todo: im lazy. make this random or something i guess. + // random:: - let new_agent = Self::lookup_agent(actor_ref).await?; - self.agent_actor = Some(new_agent.clone()); - Ok(new_agent) + agents[agent_index].actor_ref } + /* - async fn update_agents( + async fn update_vms( &mut self ) -> Result<(), Report> { - // get all agents via lookup_all and update the list that is held internally. + let mut vm_actors = RemoteActorRef::::lookup_all("agent"); + let mut get_vm_info_set = JoinSet::new(); + + while let Some(vm_actor) = vm_actors.try_next().await? { + get_vm_info_set.spawn(async move { + tracing::trace!("AgentListVMs: vm_actor={:?}", vm_actor); + + vm_actor.ask(&GetVMInfo { vmid: ulid::Ulid::nil() }).await + }) + } + } + + */ + +} +/// Periodically sends a keepalive request to all agent actors and updates their metadata. +async fn keepalive_agents( + agent_actors: Arc>> +) -> Result<(), Report> { + loop { + let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); + + while let Some(agent_actor) = agent_actors_lookup.try_next().await? { + tracing::trace!("UpdateAgents: agent_actor={:?}", agent_actor); + let metadata = agent_actor.ask(&GetAgentStatus).await?; + + let mut locked_agent_actors = agent_actors.lock().await; + + if !locked_agent_actors.contains_key(&agent_actor.id()) { + let cloned_agent_actor = agent_actor.clone(); + let task = tokio::spawn(async move { + update_agent(cloned_agent_actor).await + }); + + + locked_agent_actors.insert( + agent_actor.id(), + AgentActorDataCache { + actor_ref: agent_actor, + keepalive_task: Some(task), + metadata, + } + ); + } else { + locked_agent_actors.get_mut(&agent_actor.id()).unwrap().metadata = metadata; + } + } + } +} + +async fn update_agent( + agent_actor_ref: RemoteActorRef +) { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + if let Err(_) = agent_actor_ref.ask(&Ping).await { + break; + } } } @@ -68,26 +165,24 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); + let agent_actors = Arc::new(Mutex::new(AHashMap::new())); - let agent_actor = Self::lookup_agent(&actor_ref).await?; + let mut finished_actor = Self { + agent_actors: Arc::clone(&agent_actors), + vms: AHashMap::new(), + keepalive_task: None + }; - let ping_actor = agent_actor.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - loop { - interval.tick().await; - if let Err(err) = ping_actor.ask(&Ping).await { - warn!("Periodic ping to agent failed: {err}"); - break; - } - } + let keepalive_task = tokio::spawn(async move { + keepalive_agents(agent_actors); }); - Ok(Self { - agent_actors: Some(agent_actor), - }) + finished_actor.keepalive_task = Some(keepalive_task); + + Ok(finished_actor) } + async fn on_link_died( &mut self, actor_ref: WeakActorRef, @@ -96,53 +191,46 @@ impl Actor for SchedulerActor { ) -> Result, Self::Error> { warn!("Linked actor {id:?} died with reason {reason:?}"); - self.agent_actor = None; - let Some(actor_ref) = actor_ref.upgrade() else { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - let new_agent = Self::lookup_agent(&actor_ref).await?; + let mut locked_agent_actors = self.agent_actors.lock().await; - let ping_actor = new_agent.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - loop { - interval.tick().await; - if let Err(err) = ping_actor.ask(&Ping).await { - warn!("Periodic ping to agent failed: {err}"); - break; - } - } - }); + let Some(agent_actor_data) = locked_agent_actors.get_mut(&id) else { + return Ok(ControlFlow::Break(ActorStopReason::Killed)); + }; - self.agent_actor = Some(new_agent); + if let Some(task) = agent_actor_data.keepalive_task.take() { + trace!("Aborting keepalive task for agent {id:?}"); + task.abort(); + } + + locked_agent_actors.remove(&id); Ok(ControlFlow::Continue(())) } } -impl Message for SchedulerActor { - type Reply = Result; - async fn handle(&mut self, msg: CreateVM, ctx: &mut Context) -> Self::Reply { - let actor_ref = ctx.actor_ref(); - let first_agent = self.ensure_agent(actor_ref).await?; - match first_agent.ask(&msg).await { - Ok(reply) => Ok(reply), - Err(first_err) => { - warn!( - "CreateVM forwarding failed, clearing cached agent and retrying lookup: {first_err}" - ); - self.agent_actor = None; - let retry_agent = self.ensure_agent(actor_ref).await?; - retry_agent.ask(&msg).await.map_err(|retry_err| { - eyre!( - "failed to forward CreateVM to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" - ) - }) +impl Message for SchedulerActor { + type Reply = Result; + + async fn handle(&mut self, msg: CreateVM, _ctx: &mut Context) -> Self::Reply { + loop { + let target_agent = self.schedule_agent(&msg).await; + + match target_agent.ask(&msg).await { + Ok(reply) => { + return Ok(reply) + }, + Err(err) => { + warn!( + "CreateVM forwarding failed, trying again: {err}" + ); + } } } } @@ -231,6 +319,8 @@ impl Message for SchedulerActor { } } + + impl Message for SchedulerActor { type Reply = Pong; diff --git a/odorobo-shared/Cargo.toml b/odorobo-shared/Cargo.toml index 080a725..4c5d135 100644 --- a/odorobo-shared/Cargo.toml +++ b/odorobo-shared/Cargo.toml @@ -15,4 +15,5 @@ thiserror = { workspace = true } ulid = { workspace = true } cloud-hypervisor-client = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } -tracing = { workspace = true } \ No newline at end of file +tracing = { workspace = true } +bytesize = { workspace = true } \ No newline at end of file diff --git a/odorobo-shared/src/messages/server_status.rs b/odorobo-shared/src/messages/agent.rs similarity index 56% rename from odorobo-shared/src/messages/server_status.rs rename to odorobo-shared/src/messages/agent.rs index b6f99d5..d1a69f2 100644 --- a/odorobo-shared/src/messages/server_status.rs +++ b/odorobo-shared/src/messages/agent.rs @@ -1,11 +1,14 @@ +use bytesize::ByteSize; use kameo::Reply; use serde::{Deserialize, Serialize}; + #[derive(Serialize, Deserialize)] -pub struct GetServerStatus; +pub struct GetAgentStatus; #[derive(Serialize, Deserialize, Reply, Debug)] -pub struct ServerStatus { +pub struct AgentStatus { + pub hostname: String, pub vcpus: u32, - pub ram: u32, + pub ram: ByteSize, } diff --git a/odorobo-shared/src/messages/mod.rs b/odorobo-shared/src/messages/mod.rs index adc750d..e9ce4c7 100644 --- a/odorobo-shared/src/messages/mod.rs +++ b/odorobo-shared/src/messages/mod.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod vm; pub mod debug; -pub mod server_status; +pub mod agent; /// A request to ping the server (keepalive) #[derive(Serialize, Deserialize, Debug)] From 5d8c4160919a1ce0b14dd71c01a3b2f0f3cab314 Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 19 Apr 2026 10:32:13 -0400 Subject: [PATCH 127/189] sorta get multi agent scheduling working, but still needs a lot of work and a lot of testing --- Cargo.lock | 1 + odorobo-agent/src/actor/mod.rs | 13 +- odorobo-manager/Cargo.toml | 1 + .../actors/scheduler_actor/actor_keepalive.rs | 93 ++++++++++ .../mod.rs} | 168 +++++++----------- odorobo-shared/src/messages/agent.rs | 2 + 6 files changed, 173 insertions(+), 105 deletions(-) create mode 100644 odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs rename odorobo-manager/src/actors/{scheduler_actor.rs => scheduler_actor/mod.rs} (58%) diff --git a/Cargo.lock b/Cargo.lock index 943b1a9..2af4e2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3012,6 +3012,7 @@ version = "0.1.0" dependencies = [ "ahash", "aide", + "async-trait", "axum", "bytesize", "clap", diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index e3722b5..ce97ccc 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -400,17 +400,18 @@ impl Message for AgentActor { #[remote_message] impl Message for AgentActor { type Reply = AgentStatus; - + async fn handle( &mut self, _msg: GetAgentStatus, _ctx: &mut Context, ) -> Self::Reply { - - AgentStatus { - hostname: self.config.hostname.clone(), - vcpus: self.vcpus, - ram: self.memory + + AgentStatus { + hostname: self.config.hostname.clone(), + vcpus: self.vcpus, + ram: self.memory, + vms: vec![Ulid::new()], // todo } } } diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index 9f92e2a..e949cd0 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -38,3 +38,4 @@ url = { workspace = true } odorobo-shared = { workspace = true } stable-eyre = { workspace = true } odorobo-agent = { workspace = true } +async-trait = "0.1.89" diff --git a/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs b/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs new file mode 100644 index 0000000..2aa6ab1 --- /dev/null +++ b/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs @@ -0,0 +1,93 @@ +use std::ops::ControlFlow; +use std::sync::Arc; +use std::time::Duration; + +use ahash::AHashMap; +use async_trait::async_trait; +use kameo::prelude::*; +use libp2p::futures::TryStreamExt; +use odorobo_agent::actor::AgentActor; +use odorobo_agent::state::provisioning::actor::VMActor; +use odorobo_shared::messages::vm::*; +use odorobo_shared::messages::agent::*; +use odorobo_shared::messages::{Ping, Pong}; +use odorobo_shared::utils::vm_actor_id; +use stable_eyre::{Report, Result, eyre::eyre}; +use tokio::sync::Mutex; +use tracing::{info, warn, trace, debug}; +use ulid::Ulid; + + +pub struct ActorAgentKeepalive { + pub keepalive_task: Option>, +} + +/// Periodically sends a keepalive request to all agent actors and updates their metadata. +pub async fn keepalive_agents( + agent_actors: Arc>>, + agent_actors_keepalives: Arc>> +) -> Result<(), Report> { + loop { + let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); + + while let Some(agent_actor) = agent_actors_lookup.try_next().await? { + tracing::trace!("UpdateAgents: agent_actor={:?}", agent_actor); + + let mut locked_agent_actors_keepalives = agent_actors_keepalives.lock().await; + + if !locked_agent_actors_keepalives.contains_key(&agent_actor.id()) { + locked_agent_actors_keepalives.insert( + agent_actor.id(), + ActorAgentKeepalive::new(agent_actor, Arc::clone(&agent_actors)) + ); + } + } + } +} + +impl ActorAgentKeepalive { + fn new(actor_ref: RemoteActorRef, actor_cache: Arc>>) -> Self { + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + loop { + if let Ok(agent_status) = actor_ref.ask(&GetAgentStatus).await { + let mut locked_actor_cache = actor_cache.lock().await; + + if let Some(cached_agent_actor) = locked_actor_cache.get_mut(&actor_ref.id()) { + cached_agent_actor.metadata = agent_status; + } else { + locked_actor_cache.insert( + actor_ref.id(), + CachedAgentActor { + actor_ref: actor_ref.clone(), // todo: yea we shouldnt be cloning this every time + metadata: agent_status + } + ); + } + } + interval.tick().await; + } + }); + + Self { + keepalive_task: Some(task) + } + } +} + +pub struct VMAgentKeepalive { + pub keepalive_task: Option>, +} + + +pub struct CachedAgentActor { + pub actor_ref: RemoteActorRef, + pub metadata: AgentStatus, +} + + +pub struct CachedVMActor { + pub vm_actor_ref: RemoteActorRef, + pub agent_actor_ref: RemoteActorRef, + //pub metadata: VMStatus, // todo: set this type to whatever it should be. +} diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs similarity index 58% rename from odorobo-manager/src/actors/scheduler_actor.rs rename to odorobo-manager/src/actors/scheduler_actor/mod.rs index b6df9f7..b82d34f 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -1,8 +1,11 @@ +pub mod actor_keepalive; + use std::ops::ControlFlow; use std::sync::Arc; use std::time::Duration; use ahash::AHashMap; +use async_trait::async_trait; use kameo::prelude::*; use libp2p::futures::TryStreamExt; use odorobo_agent::actor::AgentActor; @@ -13,29 +16,41 @@ use odorobo_shared::messages::{Ping, Pong}; use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; use tokio::sync::Mutex; +use tokio::task::JoinSet; use tracing::{info, warn, trace, debug}; use ulid::Ulid; -// this arguably should be renamed or something, but I wasn't sure what data to use here. -pub struct AgentActorDataCache { - pub actor_ref: RemoteActorRef, - pub keepalive_task: Option>, - pub metadata: AgentStatus, - -} - -// this arguably should be renamed or something, but I wasn't sure what data to use here. -pub struct VMActorDataCache { - pub vm_actor_ref: RemoteActorRef, - // task that constantly looks up every agent and updates status for it - pub keepalive_task: Option>, -} - +use crate::actors::scheduler_actor::actor_keepalive::ActorAgentKeepalive; +use crate::actors::scheduler_actor::actor_keepalive::CachedAgentActor; +use crate::actors::scheduler_actor::actor_keepalive::CachedVMActor; +use crate::actors::scheduler_actor::actor_keepalive::keepalive_agents; + + +// todo: +// I (caleb) do not like the way this is written. +// I worry the Arc> is going to result in contention and issues, when we have large numbers of VMs. +// +// The contention will be caused by the fact that we do a ping to check the status of each VM/agent once per second. +// If we are running 1000s of VMs in the future, one hashmap to store all of that is eventually going to have latency problems +// Especially since the hashmap is also used whenever a user wants to make a change or things change in the swarm. +// +// I am leaving it this way because I just wanted to get things working. We may need to change the way the data is stored in the future. +// +// Either way, I think we should write a generic struct that acts as a supervisor "task". +// It would automatically manage a map for a set of actors, so you just have to run the supervisor in the task. +// This would let us reuse it other places. +// It would also likely need a hook to be able to run generic code during the keepalive function so you can also cache metadata such as VM status +// +// The keepalive tasks map can likely be kept with a RwLock, +// since it is mostly just reads unless a VMActor is actually created/destroyed, which should be less common. #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actors: Arc>>, - pub vms: AHashMap, - pub keepalive_task: Option>, + pub agent_actor_cache: Arc>>, + pub agent_actor_keepalive_tasks: Arc>>, + pub agent_keepalive_task: Option>, + + //pub vm_actor_cache: Arc>>, + //pub vm_keepalive_task: Option>, } impl SchedulerActor { @@ -67,14 +82,14 @@ impl SchedulerActor { &mut self, actor_id: &ActorId, ) -> Option> { - self.agent_actors.lock().await.get(actor_id).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.lock().await.get(actor_id).map(|data| data.actor_ref.clone()) } async fn lookup_by_hostname( &mut self, hostname: &str, ) -> Option> { - self.agent_actors.lock().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.lock().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) } async fn schedule_agent( @@ -82,13 +97,16 @@ impl SchedulerActor { msg: &CreateVM ) -> RemoteActorRef { - let agents: Vec<&AgentActorDataCache> = self.agent_actors.lock().await.values().collect(); + let locked_agent_actor_cache = self.agent_actor_cache.lock().await; + + let agents: Vec<&CachedAgentActor> = locked_agent_actor_cache.values().collect(); // warn!("randomly selecting agent"); let agent_index = 0; // todo: im lazy. make this random or something i guess. + // todo: long term make this actually like make good decisions about where to schedule // random:: - agents[agent_index].actor_ref + agents[agent_index].actor_ref.clone() } /* @@ -110,40 +128,7 @@ impl SchedulerActor { */ } -/// Periodically sends a keepalive request to all agent actors and updates their metadata. -async fn keepalive_agents( - agent_actors: Arc>> -) -> Result<(), Report> { - loop { - let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); - - while let Some(agent_actor) = agent_actors_lookup.try_next().await? { - tracing::trace!("UpdateAgents: agent_actor={:?}", agent_actor); - let metadata = agent_actor.ask(&GetAgentStatus).await?; - - let mut locked_agent_actors = agent_actors.lock().await; - - if !locked_agent_actors.contains_key(&agent_actor.id()) { - let cloned_agent_actor = agent_actor.clone(); - let task = tokio::spawn(async move { - update_agent(cloned_agent_actor).await - }); - - - locked_agent_actors.insert( - agent_actor.id(), - AgentActorDataCache { - actor_ref: agent_actor, - keepalive_task: Some(task), - metadata, - } - ); - } else { - locked_agent_actors.get_mut(&agent_actor.id()).unwrap().metadata = metadata; - } - } - } -} + async fn update_agent( agent_actor_ref: RemoteActorRef @@ -166,20 +151,26 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); let agent_actors = Arc::new(Mutex::new(AHashMap::new())); + let agent_actors_keepalives = Arc::new(Mutex::new(AHashMap::new())); - let mut finished_actor = Self { - agent_actors: Arc::clone(&agent_actors), - vms: AHashMap::new(), - keepalive_task: None - }; + //let vm_actors = Arc::new(Mutex::new(AHashMap::new())); + + let agent_actors_clone = Arc::clone(&agent_actors); + let agent_actors_keepalives_clone = Arc::clone(&agent_actors_keepalives); - let keepalive_task = tokio::spawn(async move { - keepalive_agents(agent_actors); + let agent_keepalive_task = tokio::spawn(async move { + keepalive_agents(agent_actors_clone, agent_actors_keepalives_clone).await; }); - finished_actor.keepalive_task = Some(keepalive_task); + Ok(Self { + agent_actor_cache: agent_actors, + agent_actor_keepalive_tasks: agent_actors_keepalives, + agent_keepalive_task: Some(agent_keepalive_task), + - Ok(finished_actor) + //vm_actor_cache: vm_actors, + //vm_keepalive_task: None + }) } @@ -195,13 +186,13 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - let mut locked_agent_actors = self.agent_actors.lock().await; + let mut locked_agent_actors = self.agent_actor_keepalive_tasks.lock().await; - let Some(agent_actor_data) = locked_agent_actors.get_mut(&id) else { + let Some(agent_actor_keepalive) = locked_agent_actors.get_mut(&id) else { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - if let Some(task) = agent_actor_data.keepalive_task.take() { + if let Some(task) = agent_actor_keepalive.keepalive_task.take() { trace!("Aborting keepalive task for agent {id:?}"); task.abort(); } @@ -274,6 +265,8 @@ impl Message for SchedulerActor { } } +/// this only gets data from the cache. +/// we may need a different message that actually forcibly runs/updates everything. impl Message for SchedulerActor { type Reply = Result; @@ -282,40 +275,17 @@ impl Message for SchedulerActor { msg: AgentListVMs, ctx: &mut Context, ) -> Self::Reply { - // Direct VM discovery attempt kept for reference, but it does not work reliably. - // let mut vm_actors = RemoteActorRef::::lookup_all("vm"); - // let mut vms = Vec::new(); - // - // while let Some(vm_actor) = vm_actors.try_next().await? { - // tracing::trace!("AgentListVMs: vm_actor={:?}", vm_actor); - // - // match vm_actor.ask(&GetVMInfo { vmid: ulid::Ulid::nil() }).await { - // Ok(reply) => vms.push(reply.vmid), - // Err(err) => warn!("failed to query VM actor info while listing VMs: {err}"), - // } - // } - // - // Ok(AgentListVMsReply { vms }) - let actor_ref = ctx.actor_ref(); - let first_agent = self.ensure_agent(actor_ref).await?; - match first_agent.ask(&msg).await { - Ok(reply) => Ok(reply), - Err(first_err) => { - warn!( - "AgentListVMs forwarding failed, clearing cached agent and retrying lookup: {first_err}" - ); - self.agent_actor = None; - - let retry_agent = self.ensure_agent(actor_ref).await?; - retry_agent.ask(&msg).await.map_err(|retry_err| { - eyre!( - "failed to forward AgentListVMs to agent actor after reconnect; first error: {first_err}; retry error: {retry_err}" - ) - }) - } + //let agent_actor_refs: Vec<&CachedAgentActor> = self.agent_actor_cache.lock().await.values().collect(); + + let mut vms = Vec::new(); + + for agent in self.agent_actor_cache.lock().await.values() { + vms.extend_from_slice(agent.metadata.vms.as_slice()); } + + Ok(AgentListVMsReply { vms }) } } diff --git a/odorobo-shared/src/messages/agent.rs b/odorobo-shared/src/messages/agent.rs index d1a69f2..9b9247f 100644 --- a/odorobo-shared/src/messages/agent.rs +++ b/odorobo-shared/src/messages/agent.rs @@ -1,6 +1,7 @@ use bytesize::ByteSize; use kameo::Reply; use serde::{Deserialize, Serialize}; +use ulid::Ulid; #[derive(Serialize, Deserialize)] @@ -11,4 +12,5 @@ pub struct AgentStatus { pub hostname: String, pub vcpus: u32, pub ram: ByteSize, + pub vms: Vec } From cad23fe044e84104930598ac8588f24c4fc3c24d Mon Sep 17 00:00:00 2001 From: Hero Date: Sun, 19 Apr 2026 12:55:48 -0400 Subject: [PATCH 128/189] bit more work on this. the code mostly works but i needed more stuff for testing. Signed-off-by: Hero --- Cargo.lock | 55 ++++++++++++++++--- README.md | 4 ++ odorobo-manager/Cargo.toml | 1 + .../src/actors/scheduler_actor/mod.rs | 33 ++--------- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2af4e2f..a235655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -727,7 +727,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -737,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -944,6 +955,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1007,7 +1027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1517,6 +1537,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -3023,6 +3044,7 @@ dependencies = [ "odorobo-agent", "odorobo-shared", "optional_struct", + "rand 0.10.1", "reqwest", "schemars 0.9.0", "serde", @@ -3260,7 +3282,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3272,7 +3294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3479,6 +3501,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3517,6 +3550,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "random-port" version = "0.1.1" @@ -4080,7 +4119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4091,7 +4130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/README.md b/README.md index f53d325..bdd8501 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ sudo dnf in -y clang-devel nftables # Build the Agent cargo build --release + +# create basic config +echo "{}" >> config.json + # Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus sudo ./target/release/odorobo-agent ``` diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index e949cd0..bf9fd4b 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -39,3 +39,4 @@ odorobo-shared = { workspace = true } stable-eyre = { workspace = true } odorobo-agent = { workspace = true } async-trait = "0.1.89" +rand = "0.10.1" diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index b82d34f..5f7da13 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -54,30 +54,6 @@ pub struct SchedulerActor { } impl SchedulerActor { - // async fn lookup_agent( - // &self, - // actor_ref: &ActorRef, - // ) -> Result, Report> { - - // let agent_actor_option = RemoteActorRef::::lookup("agent").await?; - - // let Some(agent_actor) = agent_actor_option else { - // warn!("No agent actor currently registered, retrying lookup"); - // tokio::time::sleep(std::time::Duration::from_secs(1)).await; - // return self.lookup_agent(actor_ref).await; - // }; - - // let agent_actor_peer_id = *agent_actor.id().peer_id().unwrap(); - // info!("Using agent actor peer id: {agent_actor_peer_id}"); - - // // remotely link actor, on link death it will be automatically unlinked - // info!("Linking agent actor: {agent_actor_peer_id}"); - // actor_ref.link_remote(&agent_actor).await?; - - // return Ok(agent_actor); - - // } - async fn lookup_by_actor_id( &mut self, actor_id: &ActorId, @@ -101,10 +77,10 @@ impl SchedulerActor { let agents: Vec<&CachedAgentActor> = locked_agent_actor_cache.values().collect(); - // warn!("randomly selecting agent"); - let agent_index = 0; // todo: im lazy. make this random or something i guess. - // todo: long term make this actually like make good decisions about where to schedule - // random:: + warn!("randomly selecting agent"); + // random agent selection, so basically round robin + // todo: improve this with something that like actually schedules agents properly + let agent_index = rand::random_range(0..agents.len()); agents[agent_index].actor_ref.clone() } @@ -192,6 +168,7 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; + if let Some(task) = agent_actor_keepalive.keepalive_task.take() { trace!("Aborting keepalive task for agent {id:?}"); task.abort(); From 414e29f96c2d700ad2ce8ae14c91edac25c2f9f3 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 20 Apr 2026 13:56:03 -0400 Subject: [PATCH 129/189] get linking working and add more logging. the caches dont seem to be working the way i expect though. need to do more testing and see if they are working correctly. I also just generally don't trust on_link_died. Signed-off-by: Hero --- .../actors/scheduler_actor/actor_keepalive.rs | 10 +++++- .../src/actors/scheduler_actor/mod.rs | 31 +++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs b/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs index 2aa6ab1..f6ab56d 100644 --- a/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs +++ b/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs @@ -17,13 +17,17 @@ use tokio::sync::Mutex; use tracing::{info, warn, trace, debug}; use ulid::Ulid; +use crate::actors::scheduler_actor::SchedulerActor; +#[derive(Debug)] pub struct ActorAgentKeepalive { pub keepalive_task: Option>, } /// Periodically sends a keepalive request to all agent actors and updates their metadata. +/// todo: i think this will crash if any of the Results error. likely need to wrap it in a closure so we can just ignore errors and keep trying. pub async fn keepalive_agents( + actor_ref: ActorRef, agent_actors: Arc>>, agent_actors_keepalives: Arc>> ) -> Result<(), Report> { @@ -36,6 +40,8 @@ pub async fn keepalive_agents( let mut locked_agent_actors_keepalives = agent_actors_keepalives.lock().await; if !locked_agent_actors_keepalives.contains_key(&agent_actor.id()) { + actor_ref.link_remote(&agent_actor).await?; + locked_agent_actors_keepalives.insert( agent_actor.id(), ActorAgentKeepalive::new(agent_actor, Arc::clone(&agent_actors)) @@ -75,17 +81,19 @@ impl ActorAgentKeepalive { } } +#[derive(Debug)] pub struct VMAgentKeepalive { pub keepalive_task: Option>, } +#[derive(Debug)] pub struct CachedAgentActor { pub actor_ref: RemoteActorRef, pub metadata: AgentStatus, } - +#[derive(Debug)] pub struct CachedVMActor { pub vm_actor_ref: RemoteActorRef, pub agent_actor_ref: RemoteActorRef, diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index 5f7da13..9a08b62 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -131,11 +131,12 @@ impl Actor for SchedulerActor { //let vm_actors = Arc::new(Mutex::new(AHashMap::new())); + let actor_ref_clone = actor_ref.clone(); let agent_actors_clone = Arc::clone(&agent_actors); let agent_actors_keepalives_clone = Arc::clone(&agent_actors_keepalives); let agent_keepalive_task = tokio::spawn(async move { - keepalive_agents(agent_actors_clone, agent_actors_keepalives_clone).await; + keepalive_agents(actor_ref_clone, agent_actors_clone, agent_actors_keepalives_clone).await; }); Ok(Self { @@ -162,19 +163,31 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - let mut locked_agent_actors = self.agent_actor_keepalive_tasks.lock().await; + let printed_pre_keepalives = self.agent_actor_keepalive_tasks.lock().await; + let printed_pre_cache = self.agent_actor_cache.lock().await; - let Some(agent_actor_keepalive) = locked_agent_actors.get_mut(&id) else { - return Ok(ControlFlow::Break(ActorStopReason::Killed)); + info!("agent actor cache data pre removal"); + info!("keepalives: {printed_pre_keepalives:?}"); + info!("actor_cache: {printed_pre_cache:?}"); + + drop(printed_pre_cache); + drop(printed_pre_keepalives); + + if let Some(mut agent_actor_keepalive) = self.agent_actor_keepalive_tasks.lock().await.remove(&id) { + if let Some(task) = agent_actor_keepalive.keepalive_task.take() { + trace!("Aborting keepalive task for agent {id:?}"); + task.abort(); + } }; + self.agent_actor_cache.lock().await.remove(&id); - if let Some(task) = agent_actor_keepalive.keepalive_task.take() { - trace!("Aborting keepalive task for agent {id:?}"); - task.abort(); - } + let printed_post_keepalives = self.agent_actor_keepalive_tasks.lock().await; + let printed_post_cache = self.agent_actor_cache.lock().await; - locked_agent_actors.remove(&id); + info!("agent actor cache data post removal"); + info!("keepalives: {printed_post_keepalives:?}"); + info!("actor_cache: {printed_post_cache:?}"); Ok(ControlFlow::Continue(())) } From f4b876b2db76c1c10f3bf5fc8c48a9809efb7ca7 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 20 Apr 2026 18:24:06 -0400 Subject: [PATCH 130/189] [scheduler_actor] remove unused code, improve printing, and confirm that caches are working Signed-off-by: Hero --- .../src/actors/scheduler_actor/mod.rs | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index 9a08b62..24d4064 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -84,6 +84,19 @@ impl SchedulerActor { agents[agent_index].actor_ref.clone() } + + async fn print_agent_caches(&self) { + let keepalives = self.agent_actor_keepalive_tasks.lock().await; + let cache = self.agent_actor_cache.lock().await; + + info!("agent actor cache data"); + for keepalive in keepalives.iter() { + info!("keepalive: {keepalive:?}"); + } + for actor in cache.iter() { + info!("actor: {actor:?}"); + } + } /* async fn update_vms( @@ -105,18 +118,6 @@ impl SchedulerActor { } - -async fn update_agent( - agent_actor_ref: RemoteActorRef -) { - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - if let Err(_) = agent_actor_ref.ask(&Ping).await { - break; - } - } -} - impl Actor for SchedulerActor { type Args = (); type Error = Report; @@ -163,15 +164,9 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - let printed_pre_keepalives = self.agent_actor_keepalive_tasks.lock().await; - let printed_pre_cache = self.agent_actor_cache.lock().await; + self.print_agent_caches().await; - info!("agent actor cache data pre removal"); - info!("keepalives: {printed_pre_keepalives:?}"); - info!("actor_cache: {printed_pre_cache:?}"); - - drop(printed_pre_cache); - drop(printed_pre_keepalives); + info!("removing agent actor from cache {id:?}"); if let Some(mut agent_actor_keepalive) = self.agent_actor_keepalive_tasks.lock().await.remove(&id) { if let Some(task) = agent_actor_keepalive.keepalive_task.take() { @@ -182,12 +177,7 @@ impl Actor for SchedulerActor { self.agent_actor_cache.lock().await.remove(&id); - let printed_post_keepalives = self.agent_actor_keepalive_tasks.lock().await; - let printed_post_cache = self.agent_actor_cache.lock().await; - - info!("agent actor cache data post removal"); - info!("keepalives: {printed_post_keepalives:?}"); - info!("actor_cache: {printed_post_cache:?}"); + self.print_agent_caches().await; Ok(ControlFlow::Continue(())) } @@ -267,8 +257,6 @@ impl Message for SchedulerActor { ) -> Self::Reply { let actor_ref = ctx.actor_ref(); - //let agent_actor_refs: Vec<&CachedAgentActor> = self.agent_actor_cache.lock().await.values().collect(); - let mut vms = Vec::new(); for agent in self.agent_actor_cache.lock().await.values() { From b0ade149eeaff85236e4cd46650144d22324eb82 Mon Sep 17 00:00:00 2001 From: Hero Date: Mon, 20 Apr 2026 20:32:33 -0400 Subject: [PATCH 131/189] work on making actor cache generic so it can be used in multiple placces. Signed-off-by: Hero --- Cargo.lock | 2 + Cargo.toml | 2 + odorobo-agent/Cargo.toml | 2 +- odorobo-manager/Cargo.toml | 2 +- .../src/actors/scheduler_actor/mod.rs | 15 +- odorobo-shared/Cargo.toml | 4 +- odorobo-shared/src/actor_cache/mod.rs | 146 ++++++++++++++++++ odorobo-shared/src/lib.rs | 1 + 8 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 odorobo-shared/src/actor_cache/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a235655..6338ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3062,6 +3062,8 @@ dependencies = [ name = "odorobo-shared" version = "0.1.0" dependencies = [ + "ahash", + "async-trait", "bytesize", "cloud-hypervisor-client", "kameo", diff --git a/Cargo.toml b/Cargo.toml index f3e06a0..0f17489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ dotenvy = "0.15" clap = { version = "4", features = ["derive", "env"] } ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} url = { version = "2.3", features = ["serde"] } +async-trait = "0.1.89" + # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. # also look in .cargo/config.toml for info on microarchitecture optimizations. diff --git a/odorobo-agent/Cargo.toml b/odorobo-agent/Cargo.toml index 77b9170..bf339c4 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo-agent/Cargo.toml @@ -37,7 +37,7 @@ zbus_systemd = { version = "0.26000.0", features = [ "zbus-async-tokio", ] } url = { version = "2.5.8", features = ["serde"] } -async-trait = "0.1.89" +async-trait = { workspace = true } kameo = { workspace = true } libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } ahash = { workspace = true } diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml index bf9fd4b..709d709 100644 --- a/odorobo-manager/Cargo.toml +++ b/odorobo-manager/Cargo.toml @@ -38,5 +38,5 @@ url = { workspace = true } odorobo-shared = { workspace = true } stable-eyre = { workspace = true } odorobo-agent = { workspace = true } -async-trait = "0.1.89" +async-trait = { workspace = true } rand = "0.10.1" diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index 24d4064..27a5e8d 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -164,20 +164,7 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - self.print_agent_caches().await; - - info!("removing agent actor from cache {id:?}"); - - if let Some(mut agent_actor_keepalive) = self.agent_actor_keepalive_tasks.lock().await.remove(&id) { - if let Some(task) = agent_actor_keepalive.keepalive_task.take() { - trace!("Aborting keepalive task for agent {id:?}"); - task.abort(); - } - }; - - self.agent_actor_cache.lock().await.remove(&id); - - self.print_agent_caches().await; + Ok(ControlFlow::Continue(())) } diff --git a/odorobo-shared/Cargo.toml b/odorobo-shared/Cargo.toml index 4c5d135..540b958 100644 --- a/odorobo-shared/Cargo.toml +++ b/odorobo-shared/Cargo.toml @@ -16,4 +16,6 @@ ulid = { workspace = true } cloud-hypervisor-client = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing = { workspace = true } -bytesize = { workspace = true } \ No newline at end of file +bytesize = { workspace = true } +async-trait = { workspace = true } +ahash = { workspace = true } diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs new file mode 100644 index 0000000..0f07f37 --- /dev/null +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -0,0 +1,146 @@ +use std::{marker::PhantomData, sync::Arc, time::Duration}; + +use ahash::AHashMap; +use async_trait::async_trait; +use kameo::{actor, prelude::*}; +use tokio::{sync::Mutex, task::JoinHandle}; +use stable_eyre::{Report, Result, eyre::eyre}; +use tracing::{info, trace}; + +pub trait DataTrait: Copy + Send + Sync + 'static {} + +#[async_trait] +pub trait ActorCacheUpdater: Sync + Send + Copy + Drop + 'static { + async fn get_actor_refs(&self, actor_ref: ActorRef) -> Vec>; + async fn on_update(&self, actor_ref: &RemoteActorRef) -> Result; +} + +#[derive(Debug)] +struct ActorCache { + parent_actor_ref: ActorRef, + pub data_cache: Arc>>, + pub keepalive_tasks: Arc>>>, + actor_finder: Option>, + + child_actor_type: PhantomData +} + +impl ActorCache { + fn new( + actor_ref: ActorRef, + updater: impl ActorCacheUpdater + ) -> Result { + + let data_cache = Arc::new(Mutex::new(AHashMap::new())); + let keepalive_tasks = Arc::new(Mutex::new(AHashMap::new())); + + let actor_cache = ActorCache { + parent_actor_ref: actor_ref, + data_cache: data_cache, + keepalive_tasks: keepalive_tasks, + actor_finder: None, + + child_actor_type: PhantomData + }; + + actor_cache.start_actor_finder(updater); + + Ok(actor_cache) + } + + /// run this function inside of the on_link_died of the ParentActor + + pub async fn on_link_died( + &self, + id: ActorId + ) { + //self.print_agent_caches().await; + + info!("removing agent actor from cache {id:?}"); + + if let Some(actor_keepalive_task) = self.keepalive_tasks.lock().await.remove(&id) { + trace!("Aborting keepalive task for agent {id:?}"); + actor_keepalive_task.abort(); + }; + + self.data_cache.lock().await.remove(&id); + + //self.print_agent_caches().await; + } + + // todo: this needs a refactor because holy crap the indention. + fn start_actor_finder( + &self, + updater: impl ActorCacheUpdater + ) { + let parent_actor_ref_clone = self.parent_actor_ref.clone(); + let keepalive_tasks_clone = Arc::clone(&self.keepalive_tasks); + let data_cache_clone = Arc::clone(&self.data_cache); + + tokio::spawn(async move { + loop { + let _ = Self::actor_finder( + parent_actor_ref_clone.clone(), + Arc::clone(&keepalive_tasks_clone), + Arc::clone(&data_cache_clone), + updater + ); + } + }); + } + + async fn actor_finder( + parent_actor_ref: ActorRef, + keepalive_tasks: Arc>>>, + data_cache: Arc>>, + updater: impl ActorCacheUpdater + ) -> Result<(), Report> { + let actor_refs = updater.get_actor_refs(parent_actor_ref).await; + + for actor_ref in actor_refs { + tracing::trace!("UpdateAgents: agent_actor={:?}", actor_ref); + + let mut locked_agent_actors_keepalives = keepalive_tasks.lock().await; + + if !locked_agent_actors_keepalives.contains_key(&actor_ref.id()) { + actor_ref.link_remote(&actor_ref).await?; + + let actor_ref_clone = actor_ref.clone(); + let data_cache_clone = Arc::clone(&data_cache); + let updater_task = tokio::spawn(async move { + Self::updater_task( + actor_ref_clone, + data_cache_clone, + updater + ); + }); + + locked_agent_actors_keepalives.insert( + actor_ref.id(), + updater_task + ); + } + } + + Ok(()) + } + + async fn updater_task( + actor_ref: RemoteActorRef, + data_cache: Arc>>, + updater: impl ActorCacheUpdater + ) { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + + loop { + if let Ok(update) = updater.on_update(&actor_ref).await { + data_cache.lock().await.insert( + actor_ref.id(), + update + ); + } + + interval.tick().await; + } + } +} diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index d5d1b54..ab20a5f 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -1,6 +1,7 @@ pub mod error; pub mod messages; pub mod utils; +pub mod actor_cache; use kameo::prelude::*; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; From fb8ebcb0acca89acc89943cbe9c531afefb89f7e Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 21 Apr 2026 14:50:17 -0400 Subject: [PATCH 132/189] likely technically working version that I just want to save. I realized I can make this even better, and so I want to go through another iteration, but I don't want to lose this version. squash this in the actual merge. Signed-off-by: Hero --- .../src/actors/scheduler_actor/mod.rs | 107 ++++++++---------- odorobo-shared/src/actor_cache/mod.rs | 96 +++++++++++----- odorobo-shared/src/messages/agent.rs | 2 +- 3 files changed, 121 insertions(+), 84 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index 27a5e8d..3460e3c 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -10,6 +10,8 @@ use kameo::prelude::*; use libp2p::futures::TryStreamExt; use odorobo_agent::actor::AgentActor; use odorobo_agent::state::provisioning::actor::VMActor; +use odorobo_shared::actor_cache::ActorCache; +use odorobo_shared::actor_cache::ActorCacheUpdater; use odorobo_shared::messages::vm::*; use odorobo_shared::messages::agent::*; use odorobo_shared::messages::{Ping, Pong}; @@ -21,33 +23,15 @@ use tracing::{info, warn, trace, debug}; use ulid::Ulid; use crate::actors::scheduler_actor::actor_keepalive::ActorAgentKeepalive; -use crate::actors::scheduler_actor::actor_keepalive::CachedAgentActor; +//use crate::actors::scheduler_actor::actor_keepalive::CachedAgentActor; use crate::actors::scheduler_actor::actor_keepalive::CachedVMActor; use crate::actors::scheduler_actor::actor_keepalive::keepalive_agents; -// todo: -// I (caleb) do not like the way this is written. -// I worry the Arc> is going to result in contention and issues, when we have large numbers of VMs. -// -// The contention will be caused by the fact that we do a ping to check the status of each VM/agent once per second. -// If we are running 1000s of VMs in the future, one hashmap to store all of that is eventually going to have latency problems -// Especially since the hashmap is also used whenever a user wants to make a change or things change in the swarm. -// -// I am leaving it this way because I just wanted to get things working. We may need to change the way the data is stored in the future. -// -// Either way, I think we should write a generic struct that acts as a supervisor "task". -// It would automatically manage a map for a set of actors, so you just have to run the supervisor in the task. -// This would let us reuse it other places. -// It would also likely need a hook to be able to run generic code during the keepalive function so you can also cache metadata such as VM status -// -// The keepalive tasks map can likely be kept with a RwLock, -// since it is mostly just reads unless a VMActor is actually created/destroyed, which should be less common. + #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actor_cache: Arc>>, - pub agent_actor_keepalive_tasks: Arc>>, - pub agent_keepalive_task: Option>, + pub agent_actor_cache: ActorCache //pub vm_actor_cache: Arc>>, //pub vm_keepalive_task: Option>, @@ -58,14 +42,14 @@ impl SchedulerActor { &mut self, actor_id: &ActorId, ) -> Option> { - self.agent_actor_cache.lock().await.get(actor_id).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.lock_data_cache().await.get(actor_id).map(|data| data.actor_ref.clone()) } async fn lookup_by_hostname( &mut self, hostname: &str, ) -> Option> { - self.agent_actor_cache.lock().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.lock_data_cache().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) } async fn schedule_agent( @@ -73,7 +57,7 @@ impl SchedulerActor { msg: &CreateVM ) -> RemoteActorRef { - let locked_agent_actor_cache = self.agent_actor_cache.lock().await; + let locked_agent_actor_cache = self.agent_actor_cache.lock_data_cache().await; let agents: Vec<&CachedAgentActor> = locked_agent_actor_cache.values().collect(); @@ -85,18 +69,7 @@ impl SchedulerActor { agents[agent_index].actor_ref.clone() } - async fn print_agent_caches(&self) { - let keepalives = self.agent_actor_keepalive_tasks.lock().await; - let cache = self.agent_actor_cache.lock().await; - info!("agent actor cache data"); - for keepalive in keepalives.iter() { - info!("keepalive: {keepalive:?}"); - } - for actor in cache.iter() { - info!("actor: {actor:?}"); - } - } /* async fn update_vms( @@ -118,6 +91,45 @@ impl SchedulerActor { } + + +#[derive(Copy, Clone)] +struct AgentActorCacheUpdater; + +#[derive(Debug, Clone)] +pub struct CachedAgentActor { + pub actor_ref: RemoteActorRef, + pub metadata: AgentStatus, +} + +#[async_trait] +impl ActorCacheUpdater for AgentActorCacheUpdater { + async fn get_actor_refs(&self) -> Result>> { + let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); + let mut actor_ref_vec = Vec::new(); + + while let Some(agent_actor) = agent_actors_lookup.try_next().await? { + actor_ref_vec.push(agent_actor); + } + + Ok(actor_ref_vec) + } + + async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result { + let output_actor_ref = match previous_value { + Some(value) => value.actor_ref, + _ => actor_ref.clone(), + }; + + Ok(CachedAgentActor { + actor_ref: output_actor_ref, + metadata: actor_ref.ask(&GetAgentStatus).await? + }) + } +} + + + impl Actor for SchedulerActor { type Args = (); type Error = Report; @@ -127,27 +139,8 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); - let agent_actors = Arc::new(Mutex::new(AHashMap::new())); - let agent_actors_keepalives = Arc::new(Mutex::new(AHashMap::new())); - - //let vm_actors = Arc::new(Mutex::new(AHashMap::new())); - - let actor_ref_clone = actor_ref.clone(); - let agent_actors_clone = Arc::clone(&agent_actors); - let agent_actors_keepalives_clone = Arc::clone(&agent_actors_keepalives); - - let agent_keepalive_task = tokio::spawn(async move { - keepalive_agents(actor_ref_clone, agent_actors_clone, agent_actors_keepalives_clone).await; - }); - Ok(Self { - agent_actor_cache: agent_actors, - agent_actor_keepalive_tasks: agent_actors_keepalives, - agent_keepalive_task: Some(agent_keepalive_task), - - - //vm_actor_cache: vm_actors, - //vm_keepalive_task: None + agent_actor_cache: ActorCache::new(AgentActorCacheUpdater)?, }) } @@ -164,7 +157,7 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - + self.agent_actor_cache.on_link_died(id); Ok(ControlFlow::Continue(())) } @@ -246,7 +239,7 @@ impl Message for SchedulerActor { let mut vms = Vec::new(); - for agent in self.agent_actor_cache.lock().await.values() { + for agent in self.agent_actor_cache.lock_data_cache().await.values() { vms.extend_from_slice(agent.metadata.vms.as_slice()); } diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index 0f07f37..11334f7 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -3,39 +3,49 @@ use std::{marker::PhantomData, sync::Arc, time::Duration}; use ahash::AHashMap; use async_trait::async_trait; use kameo::{actor, prelude::*}; -use tokio::{sync::Mutex, task::JoinHandle}; +use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, trace}; -pub trait DataTrait: Copy + Send + Sync + 'static {} - +// the selfs here are because #[async_trait] -pub trait ActorCacheUpdater: Sync + Send + Copy + Drop + 'static { - async fn get_actor_refs(&self, actor_ref: ActorRef) -> Vec>; - async fn on_update(&self, actor_ref: &RemoteActorRef) -> Result; +pub trait ActorCacheUpdater: Sync + Send + Copy + 'static { + // todo: this could probably be better if it was an iterator, but I am lazy and don't want to right now. + async fn get_actor_refs(&self) -> Result>>; + async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result; } + +// todo: +// I (caleb) do not like the way this is written. +// I worry the Arc> is going to result in contention and issues, when we have large numbers of VMs. +// +// The contention will be caused by the fact that we do a ping to check the status of each VM/agent once per second. +// If we are running 1000s of VMs in the future, one hashmap to store all of that is eventually going to have latency problems +// Especially since the hashmap is also used whenever a user wants to make a change or things change in the swarm. +// +// I am leaving it this way because I just wanted to get things working. We may need to change the way the data is stored in the future. +// My suggestions are either replacing it with a concurrent map, or using a RwLock. Either one might help, but I am not dealing with it now. #[derive(Debug)] -struct ActorCache { - parent_actor_ref: ActorRef, - pub data_cache: Arc>>, - pub keepalive_tasks: Arc>>>, +pub struct ActorCache { + data_cache: Arc>>, + keepalive_tasks: Arc>>>, actor_finder: Option>, child_actor_type: PhantomData } -impl ActorCache { - fn new( - actor_ref: ActorRef, - updater: impl ActorCacheUpdater +// todo: impl Drop to automatically kill all the keepalive_tasks and the actor_finder task. + +impl ActorCache { + pub fn new( + updater: impl ActorCacheUpdater ) -> Result { let data_cache = Arc::new(Mutex::new(AHashMap::new())); let keepalive_tasks = Arc::new(Mutex::new(AHashMap::new())); let actor_cache = ActorCache { - parent_actor_ref: actor_ref, data_cache: data_cache, keepalive_tasks: keepalive_tasks, actor_finder: None, @@ -68,19 +78,21 @@ impl MutexGuard<'_, AHashMap> { + self.data_cache.lock().await + } + // todo: this needs a refactor because holy crap the indention. fn start_actor_finder( &self, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater ) { - let parent_actor_ref_clone = self.parent_actor_ref.clone(); let keepalive_tasks_clone = Arc::clone(&self.keepalive_tasks); let data_cache_clone = Arc::clone(&self.data_cache); tokio::spawn(async move { loop { let _ = Self::actor_finder( - parent_actor_ref_clone.clone(), Arc::clone(&keepalive_tasks_clone), Arc::clone(&data_cache_clone), updater @@ -90,12 +102,11 @@ impl, keepalive_tasks: Arc>>>, data_cache: Arc>>, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater ) -> Result<(), Report> { - let actor_refs = updater.get_actor_refs(parent_actor_ref).await; + let actor_refs = updater.get_actor_refs().await?; for actor_ref in actor_refs { tracing::trace!("UpdateAgents: agent_actor={:?}", actor_ref); @@ -112,7 +123,7 @@ impl, data_cache: Arc>>, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater ) { let mut interval = tokio::time::interval(Duration::from_secs(1)); loop { - if let Ok(update) = updater.on_update(&actor_ref).await { + let actor_id = actor_ref.id(); + + let mut previous_value_option = None; + + + + let locked_data_cache = data_cache.lock().await; + + if let Some(data_ref) = locked_data_cache.get(&actor_id) { + previous_value_option = Some(data_ref.clone()); + } + + // done to very explicility make sure it does not stay locked. + drop(locked_data_cache); + + + + if let Ok(update) = updater.on_update(&actor_ref, previous_value_option).await { data_cache.lock().await.insert( - actor_ref.id(), - update + actor_id, + update.clone() ); } interval.tick().await; } } + + /* + /// todo: implement display + async fn print_agent_caches(&self) { + let keepalives = self.agent_actor_keepalive_tasks.lock().await; + let cache = self.agent_actor_cache.lock().await; + + info!("agent actor cache data"); + for keepalive in keepalives.iter() { + info!("keepalive: {keepalive:?}"); + } + for actor in cache.iter() { + info!("actor: {actor:?}"); + } + } + */ } diff --git a/odorobo-shared/src/messages/agent.rs b/odorobo-shared/src/messages/agent.rs index 9b9247f..c3e1e36 100644 --- a/odorobo-shared/src/messages/agent.rs +++ b/odorobo-shared/src/messages/agent.rs @@ -7,7 +7,7 @@ use ulid::Ulid; #[derive(Serialize, Deserialize)] pub struct GetAgentStatus; -#[derive(Serialize, Deserialize, Reply, Debug)] +#[derive(Serialize, Deserialize, Reply, Debug, Clone)] pub struct AgentStatus { pub hostname: String, pub vcpus: u32, From 6087972d6a5127cb6e477913137c2853c6aff94f Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 21 Apr 2026 14:58:01 -0400 Subject: [PATCH 133/189] complaining about realizing I need a derive macro Signed-off-by: Hero --- odorobo-shared/src/actor_cache/mod.rs | 48 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index 11334f7..bef4b1f 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -7,7 +7,20 @@ use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, trace}; -// the selfs here are because +// future refactor TODO because I don't know how to do it now. +// The best way to make this would be that you crate a struct with #[derive(ActorCache)] +// and then you impl ActorCache with setting the ChildActor and Data as types similar to https://github.com/tqwewe/kameo/blob/1d498c0566b613b9afe6d54965c4b191c84432e0/src/actor.rs#L122 +// you could then just implement these get_actor_ref and on_update methods during that. +// we also would likely want default methods that just let you lookup_all for a specific actor string. +// you would also likely want to change get_actor_refs to return an iterator during this if you are doing this anyway. +// the problem is to do this you need to write a derive macro and I have no clue how to do that. +// and learning that now is not something i should spend tiem doing. +// so unfortunately instead I have to use self inside of the ActorCacheUpdater trait to make it work. +// Which I hate +// this would also make it where we dont need two structs, one for data and one for the update function hooks. +// I thnk it would also likely make a lot of the generic types simpler. + + #[async_trait] pub trait ActorCacheUpdater: Sync + Send + Copy + 'static { // todo: this could probably be better if it was an iterator, but I am lazy and don't want to right now. @@ -37,6 +50,23 @@ pub struct ActorCache ActorCache { pub fn new( updater: impl ActorCacheUpdater @@ -171,20 +201,4 @@ impl Actor interval.tick().await; } } - - /* - /// todo: implement display - async fn print_agent_caches(&self) { - let keepalives = self.agent_actor_keepalive_tasks.lock().await; - let cache = self.agent_actor_cache.lock().await; - - info!("agent actor cache data"); - for keepalive in keepalives.iter() { - info!("keepalive: {keepalive:?}"); - } - for actor in cache.iter() { - info!("actor: {actor:?}"); - } - } - */ } From c8172a275d878902bb94ef98d27d1ca1927a4110 Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 21 Apr 2026 15:57:35 -0400 Subject: [PATCH 134/189] remove old actor keepalive code and bug fixes for ActorCache Signed-off-by: Hero --- .../actors/scheduler_actor/actor_keepalive.rs | 101 ------------------ .../src/actors/scheduler_actor/mod.rs | 18 ++-- odorobo-shared/src/actor_cache/mod.rs | 61 ++++++----- 3 files changed, 43 insertions(+), 137 deletions(-) delete mode 100644 odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs diff --git a/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs b/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs deleted file mode 100644 index f6ab56d..0000000 --- a/odorobo-manager/src/actors/scheduler_actor/actor_keepalive.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::ops::ControlFlow; -use std::sync::Arc; -use std::time::Duration; - -use ahash::AHashMap; -use async_trait::async_trait; -use kameo::prelude::*; -use libp2p::futures::TryStreamExt; -use odorobo_agent::actor::AgentActor; -use odorobo_agent::state::provisioning::actor::VMActor; -use odorobo_shared::messages::vm::*; -use odorobo_shared::messages::agent::*; -use odorobo_shared::messages::{Ping, Pong}; -use odorobo_shared::utils::vm_actor_id; -use stable_eyre::{Report, Result, eyre::eyre}; -use tokio::sync::Mutex; -use tracing::{info, warn, trace, debug}; -use ulid::Ulid; - -use crate::actors::scheduler_actor::SchedulerActor; - -#[derive(Debug)] -pub struct ActorAgentKeepalive { - pub keepalive_task: Option>, -} - -/// Periodically sends a keepalive request to all agent actors and updates their metadata. -/// todo: i think this will crash if any of the Results error. likely need to wrap it in a closure so we can just ignore errors and keep trying. -pub async fn keepalive_agents( - actor_ref: ActorRef, - agent_actors: Arc>>, - agent_actors_keepalives: Arc>> -) -> Result<(), Report> { - loop { - let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); - - while let Some(agent_actor) = agent_actors_lookup.try_next().await? { - tracing::trace!("UpdateAgents: agent_actor={:?}", agent_actor); - - let mut locked_agent_actors_keepalives = agent_actors_keepalives.lock().await; - - if !locked_agent_actors_keepalives.contains_key(&agent_actor.id()) { - actor_ref.link_remote(&agent_actor).await?; - - locked_agent_actors_keepalives.insert( - agent_actor.id(), - ActorAgentKeepalive::new(agent_actor, Arc::clone(&agent_actors)) - ); - } - } - } -} - -impl ActorAgentKeepalive { - fn new(actor_ref: RemoteActorRef, actor_cache: Arc>>) -> Self { - let task = tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - loop { - if let Ok(agent_status) = actor_ref.ask(&GetAgentStatus).await { - let mut locked_actor_cache = actor_cache.lock().await; - - if let Some(cached_agent_actor) = locked_actor_cache.get_mut(&actor_ref.id()) { - cached_agent_actor.metadata = agent_status; - } else { - locked_actor_cache.insert( - actor_ref.id(), - CachedAgentActor { - actor_ref: actor_ref.clone(), // todo: yea we shouldnt be cloning this every time - metadata: agent_status - } - ); - } - } - interval.tick().await; - } - }); - - Self { - keepalive_task: Some(task) - } - } -} - -#[derive(Debug)] -pub struct VMAgentKeepalive { - pub keepalive_task: Option>, -} - - -#[derive(Debug)] -pub struct CachedAgentActor { - pub actor_ref: RemoteActorRef, - pub metadata: AgentStatus, -} - -#[derive(Debug)] -pub struct CachedVMActor { - pub vm_actor_ref: RemoteActorRef, - pub agent_actor_ref: RemoteActorRef, - //pub metadata: VMStatus, // todo: set this type to whatever it should be. -} diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor/mod.rs index 3460e3c..5849655 100644 --- a/odorobo-manager/src/actors/scheduler_actor/mod.rs +++ b/odorobo-manager/src/actors/scheduler_actor/mod.rs @@ -1,5 +1,3 @@ -pub mod actor_keepalive; - use std::ops::ControlFlow; use std::sync::Arc; use std::time::Duration; @@ -22,16 +20,10 @@ use tokio::task::JoinSet; use tracing::{info, warn, trace, debug}; use ulid::Ulid; -use crate::actors::scheduler_actor::actor_keepalive::ActorAgentKeepalive; -//use crate::actors::scheduler_actor::actor_keepalive::CachedAgentActor; -use crate::actors::scheduler_actor::actor_keepalive::CachedVMActor; -use crate::actors::scheduler_actor::actor_keepalive::keepalive_agents; - - #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actor_cache: ActorCache + pub agent_actor_cache: ActorCache //pub vm_actor_cache: Arc>>, //pub vm_keepalive_task: Option>, @@ -140,7 +132,7 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); Ok(Self { - agent_actor_cache: ActorCache::new(AgentActorCacheUpdater)?, + agent_actor_cache: ActorCache::new(actor_ref, AgentActorCacheUpdater)?, }) } @@ -156,8 +148,12 @@ impl Actor for SchedulerActor { let Some(actor_ref) = actor_ref.upgrade() else { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; + + self.agent_actor_cache.info().await; - self.agent_actor_cache.on_link_died(id); + self.agent_actor_cache.on_link_died(id).await; + + self.agent_actor_cache.info().await; Ok(ControlFlow::Continue(())) } diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index bef4b1f..b58702e 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -7,6 +7,8 @@ use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, trace}; +use std::fmt; + // future refactor TODO because I don't know how to do it now. // The best way to make this would be that you crate a struct with #[derive(ActorCache)] // and then you impl ActorCache with setting the ChildActor and Data as types similar to https://github.com/tqwewe/kameo/blob/1d498c0566b613b9afe6d54965c4b191c84432e0/src/actor.rs#L122 @@ -18,11 +20,11 @@ use tracing::{info, trace}; // so unfortunately instead I have to use self inside of the ActorCacheUpdater trait to make it work. // Which I hate // this would also make it where we dont need two structs, one for data and one for the update function hooks. -// I thnk it would also likely make a lot of the generic types simpler. +// I thnk it would also likely make a lot of the generic types simpler since hopefully their trait bounds would only be in one place. #[async_trait] -pub trait ActorCacheUpdater: Sync + Send + Copy + 'static { +pub trait ActorCacheUpdater: Sync + Send + Copy + 'static { // todo: this could probably be better if it was an iterator, but I am lazy and don't want to right now. async fn get_actor_refs(&self) -> Result>>; async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result; @@ -40,7 +42,8 @@ pub trait ActorCacheUpdater { +pub struct ActorCache { + parent_actor_ref: ActorRef, data_cache: Arc>>, keepalive_tasks: Arc>>>, actor_finder: Option>, @@ -50,25 +53,9 @@ pub struct ActorCache ActorCache { +impl ActorCache { pub fn new( + parent_actor_ref: ActorRef, updater: impl ActorCacheUpdater ) -> Result { @@ -76,6 +63,7 @@ impl Actor let keepalive_tasks = Arc::new(Mutex::new(AHashMap::new())); let actor_cache = ActorCache { + parent_actor_ref: parent_actor_ref.clone(), data_cache: data_cache, keepalive_tasks: keepalive_tasks, actor_finder: None, @@ -83,7 +71,7 @@ impl Actor child_actor_type: PhantomData }; - actor_cache.start_actor_finder(updater); + actor_cache.start_actor_finder(parent_actor_ref, updater); Ok(actor_cache) } @@ -112,39 +100,48 @@ impl Actor self.data_cache.lock().await } - // todo: this needs a refactor because holy crap the indention. fn start_actor_finder( &self, + parent_actor_ref: ActorRef, updater: impl ActorCacheUpdater ) { let keepalive_tasks_clone = Arc::clone(&self.keepalive_tasks); let data_cache_clone = Arc::clone(&self.data_cache); tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); loop { + info!("running actor_finder"); let _ = Self::actor_finder( + parent_actor_ref.clone(), Arc::clone(&keepalive_tasks_clone), Arc::clone(&data_cache_clone), updater - ); + ).await; + + interval.tick().await; } }); } async fn actor_finder( + parent_actor_ref: ActorRef, keepalive_tasks: Arc>>>, data_cache: Arc>>, updater: impl ActorCacheUpdater ) -> Result<(), Report> { let actor_refs = updater.get_actor_refs().await?; + info!("actor_finder actor_refs: {actor_refs:?}"); + for actor_ref in actor_refs { tracing::trace!("UpdateAgents: agent_actor={:?}", actor_ref); let mut locked_agent_actors_keepalives = keepalive_tasks.lock().await; if !locked_agent_actors_keepalives.contains_key(&actor_ref.id()) { - actor_ref.link_remote(&actor_ref).await?; + + parent_actor_ref.link_remote(&actor_ref).await?; let actor_ref_clone = actor_ref.clone(); let data_cache_clone = Arc::clone(&data_cache); @@ -201,4 +198,18 @@ impl Actor interval.tick().await; } } + + // todo: get cappy to tell me how you are supposed to do this properly + pub async fn info(&self) { + let keepalives = self.keepalive_tasks.lock().await; + let cache = self.data_cache.lock().await; + + info!("agent actor cache data"); + for keepalive in keepalives.iter() { + info!("keepalive: {keepalive:?}"); + } + for data in cache.iter() { + info!("data: {data:?}"); + } + } } From 13a42ee5d94d888300eea95e067e82fb9ca8d185 Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 21 Apr 2026 16:06:01 -0400 Subject: [PATCH 135/189] move scheduler_actor back to a file rather then a folder module. Signed-off-by: Hero --- .../src/actors/{scheduler_actor/mod.rs => scheduler_actor.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename odorobo-manager/src/actors/{scheduler_actor/mod.rs => scheduler_actor.rs} (100%) diff --git a/odorobo-manager/src/actors/scheduler_actor/mod.rs b/odorobo-manager/src/actors/scheduler_actor.rs similarity index 100% rename from odorobo-manager/src/actors/scheduler_actor/mod.rs rename to odorobo-manager/src/actors/scheduler_actor.rs From d56c852557744fda3318d9e44071ccfd139139cb Mon Sep 17 00:00:00 2001 From: Hero Date: Tue, 21 Apr 2026 16:36:45 -0400 Subject: [PATCH 136/189] reduce warnings Signed-off-by: Hero --- odorobo-manager/src/actors/scheduler_actor.rs | 20 ++++++------------- odorobo-shared/src/actor_cache/mod.rs | 4 ++-- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 5849655..ebd2ca1 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -1,8 +1,5 @@ use std::ops::ControlFlow; -use std::sync::Arc; -use std::time::Duration; -use ahash::AHashMap; use async_trait::async_trait; use kameo::prelude::*; use libp2p::futures::TryStreamExt; @@ -15,10 +12,7 @@ use odorobo_shared::messages::agent::*; use odorobo_shared::messages::{Ping, Pong}; use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; -use tokio::sync::Mutex; -use tokio::task::JoinSet; -use tracing::{info, warn, trace, debug}; -use ulid::Ulid; +use tracing::{info, warn}; #[derive(RemoteActor)] @@ -145,14 +139,14 @@ impl Actor for SchedulerActor { ) -> Result, Self::Error> { warn!("Linked actor {id:?} died with reason {reason:?}"); - let Some(actor_ref) = actor_ref.upgrade() else { + let Some(_) = actor_ref.upgrade() else { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - + self.agent_actor_cache.info().await; self.agent_actor_cache.on_link_died(id).await; - + self.agent_actor_cache.info().await; Ok(ControlFlow::Continue(())) @@ -228,11 +222,9 @@ impl Message for SchedulerActor { async fn handle( &mut self, - msg: AgentListVMs, - ctx: &mut Context, + _msg: AgentListVMs, + _ctx: &mut Context, ) -> Self::Reply { - let actor_ref = ctx.actor_ref(); - let mut vms = Vec::new(); for agent in self.agent_actor_cache.lock_data_cache().await.values() { diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index b58702e..0f72a94 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -2,9 +2,9 @@ use std::{marker::PhantomData, sync::Arc, time::Duration}; use ahash::AHashMap; use async_trait::async_trait; -use kameo::{actor, prelude::*}; +use kameo::{prelude::*}; use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; -use stable_eyre::{Report, Result, eyre::eyre}; +use stable_eyre::{Report, Result}; use tracing::{info, trace}; use std::fmt; From 760e4201dfe64c2a2debccfa765b6989ed126b33 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Fri, 24 Apr 2026 17:10:42 -0500 Subject: [PATCH 137/189] remove old scheduler actor http code --- odorobo-manager/src/scheduler_actor_http.rs | 310 -------------------- 1 file changed, 310 deletions(-) delete mode 100644 odorobo-manager/src/scheduler_actor_http.rs diff --git a/odorobo-manager/src/scheduler_actor_http.rs b/odorobo-manager/src/scheduler_actor_http.rs deleted file mode 100644 index 03d01c8..0000000 --- a/odorobo-manager/src/scheduler_actor_http.rs +++ /dev/null @@ -1,310 +0,0 @@ -use axum::extract::State; -use axum::http::StatusCode; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use kameo::prelude::*; -use libp2p::PeerId; -use libp2p::futures::TryStreamExt; -use odorobo_shared::messages::server_status::{GetServerStatus}; -use serde::{Deserialize, Serialize}; -use utoipa::OpenApi; -use uuid::Uuid; -//use odorobo_shared::odorobo::server_actor::ServerActor; -use odorobo_agent::actor::AgentActor; -use odorobo_shared::messages::create_vm::*; -use stable_eyre::{Report, Result}; - -#[derive(RemoteActor)] -pub struct SchedulerActor {} - -const PING_RETURN_VALUE: &str = "pong"; -const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; - -const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: make this based on EXTERNAL_HTTP_ADDRESS. const compile time stuff is a pain. - -impl Actor for SchedulerActor { - type Args = Self; - type Error = Report; - - async fn on_start(state: Self::Args, actor_ref: ActorRef) -> Result { - on_start_debug(state, actor_ref).await - - //on_start_normal(state, actor_ref).await - - //Ok(state) - } -} - -async fn on_start_debug( - state: SchedulerActor, - actor_ref: ActorRef, -) -> Result { - let peer_id = actor_ref.id().peer_id().unwrap().clone(); - - println!("Actor started! Scheduler peer id:{peer_id}"); - - let agent_actor = RemoteActorRef::::lookup("agent") - .await? - .unwrap(); - - let agent_actor_peer_id = agent_actor.id().peer_id().unwrap().clone(); - - println!("Agent actor peer id: {agent_actor_peer_id}"); - - agent_actor.ask(&CreateVM { - vm_id: Default::default(), - config: Default::default(), - }); - - Ok(state) -} - -async fn on_start_normal( - state: SchedulerActor, - actor_ref: ActorRef, -) -> Result { - let axum_router = Router::new() - .route("/ping", get(|| async { PING_RETURN_VALUE })) - .route("/create_vm", post(create_vm)) - .route("/delete_vm", post(delete_vm)) - .route("/update_vm", post(update_vm)) - .route("/get_vm", post(get_vm)) - .route("/create_volume", post(create_vm)) - .route("/delete_volume", post(delete_vm)) - .route("/update_volume", post(update_vm)) - .route("/get_volume", post(get_vm)) - .route("/drain_server", post(drain_server)) - // .route("/get_servers", post(get_servers)) - .with_state(actor_ref); - - println!("starting axum server at {}", EXTERNAL_HTTP_URL); - - // run our app with hyper, listening globally on port 3000 - tokio::spawn(async move { - let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) - .await - .unwrap(); - axum::serve(listener, axum_router).await.unwrap(); - }); - - // spin loop until the axum server starts responding to requests - // TODO: if anyone has a better way to detect the axum server is up, change it to that. - - let mut count = 0; - loop { - count += 1; - println!("attempting to hit axum server, attempt {}", count); - - let resp_result: Result<()> = async { - let resp = reqwest::get(EXTERNAL_HTTP_URL.to_owned() + "/ping") - .await? - .text() - .await?; - - if resp != PING_RETURN_VALUE { - return Err(stable_eyre::eyre::eyre!("invalid ping response")); - } - - Ok(()) - } - .await; - - match resp_result { - Ok(()) => { - break; - } - Err(e) => { - println!("{}", e) - } - } - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - - println!("Actor started"); - Ok(state) -} -/* -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct CreateVM { - pub uuid: Uuid, - pub name: String, - pub vcpus: u32, - pub ram: u32, - pub image: String, -} -*/ - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GenericSuccessResponse { - pub success: bool, -} - -// no response. just use status code 200 vs not 200 for if it worked. -#[utoipa::path( - post, - path = "/create_vm", - request_body(content = CreateVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -pub async fn create_vm( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - todo!() -} - -pub type UpdateVM = CreateVM; - -#[utoipa::path( - post, - path = "/update_vm", - request_body(content = UpdateVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn update_vm( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - todo!() -} - -pub type DeleteVM = GetVM; -#[utoipa::path( - post, - path = "/delete_vm", - request_body(content = DeleteVM, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn delete_vm( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - todo!() -} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetVM { - pub uuid: Uuid, -} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetVMResponse { - pub cpus: bool, -} -#[utoipa::path( - post, - path = "/get_vm", - request_body(content = GetVM, content_type = "application/json"), - responses( - (status = 200, body = GetVMResponse) - ) -)] -async fn get_vm( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - todo!() -} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct DrainServer {} - -#[utoipa::path( - post, - path = "/get_vm", - request_body(content = DrainServer, content_type = "application/json"), - responses( - (status = 200, body = GenericSuccessResponse) - ) -)] -async fn drain_server( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - todo!() -} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetServers { - pub start_index: u64, - pub end_index: u64, -} - -// this is a debug thing to make utoipa happy because we havent remove it. -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct ServerStatus {} - -#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] -pub struct GetServersResponse { - pub total_servers: u64, - pub servers: Vec, -} -/* -// this again is commented out because it isnt not fully working and we need this for debug reasons to test panic stuff. -#[utoipa::path( - post, - path = "/get_servers", - request_body(content = GetServers, content_type = "application/json"), - responses( - (status = 200, body = GetServersResponse) - ) -)] -async fn get_servers( - State(state): State>, - Json(payload): Json, -) -> (StatusCode, String) { - let mut servers: Vec<(PeerId, ServerStatus)> = Vec::new(); - - let server_actor_response: Result<()> = async { - println!("getting server actors"); - - let mut server_actors = RemoteActorRef::::lookup_all("agent"); - - while let Some(server_actor) = server_actors.try_next().await? { - // Send message to each instance - println!("asking {:?}", server_actor); - let result = server_actor.ask(&GetServerStatus {}).await?; - println!("result {:?}", result); - - if let Some(peerId) = server_actor.id().peer_id() { - servers.push((*peerId, result)); - } - } - - Ok(()) - } - .await; - - match server_actor_response { - Ok(()) => (StatusCode::OK, serde_json::to_string(&servers).unwrap()), - _ => (StatusCode::INTERNAL_SERVER_ERROR, "".parse().unwrap()), - } -} -*/ -pub fn gen_openapi_spec() -> String { - #[derive(OpenApi)] - #[openapi( - components(schemas( - CreateVM, - UpdateVM, - DeleteVM, - GetVM, - DrainServer, - // GetServers, - GenericSuccessResponse, - GetVMResponse, - GetServersResponse - )), - // paths(get_servers) - )] - struct ApiDoc; - - ApiDoc::openapi().to_pretty_json().unwrap() -} From c88ffc166d6bbb703346ea3cf2cf49159d04cf09 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Fri, 24 Apr 2026 17:11:07 -0500 Subject: [PATCH 138/189] add caching of vm actors to scheduler actor Signed-off-by: Caleb Jones --- odorobo-agent/src/actor/mod.rs | 26 +++++---- odorobo-agent/src/main.rs | 4 +- odorobo-manager/src/actors/scheduler_actor.rs | 54 +++++++++++++++++-- odorobo-shared/src/actor_names.rs | 4 ++ odorobo-shared/src/lib.rs | 1 + odorobo-shared/src/messages/vm.rs | 4 +- 6 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 odorobo-shared/src/actor_names.rs diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index ce97ccc..5dcf547 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -4,8 +4,7 @@ use bytesize::ByteSize; use ipnet::Ipv4Net; use kameo::prelude::*; use odorobo_shared::{ - messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, - utils::vm_actor_id, + actor_names::VM, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, utils::vm_actor_id }; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; @@ -240,7 +239,7 @@ impl Message for AgentActor { VMActor::spawn_link(ctx.actor_ref(), (vmid, Some(msg.config.clone()))).await; let _ = actor_ref.register(vm_actor_id(vmid)).await; - let _ = actor_ref.register("vm").await; + let _ = actor_ref.register(VM).await; self.vms.insert(vmid, actor_ref.clone()); info!(?vmid, "VM Spawned successfully"); @@ -263,7 +262,7 @@ impl Message for AgentActor { let actor_ref = VMActor::spawn_link(ctx.actor_ref(), (vmid, None)).await; let _ = actor_ref.register(vm_actor_id(vmid)).await; - let _ = actor_ref.register("vm").await; + let _ = actor_ref.register(VM).await; self.vms.insert(vmid, actor_ref.clone()); // now ask the VM actor to handle the migration receive @@ -337,13 +336,20 @@ impl Message for AgentActor { msg: GetVMInfo, ctx: &mut Context, ) -> Self::Reply { - let vmid = msg.vmid; - - match Self::lookup_vm_actor(vmid).await { - Some(actor_ref) => ctx.forward(&actor_ref, msg).await, + // todo: caleb, i think this code can be cleaned up most likely, but im not sure what the best way to write it is unfortunately. + match msg.vmid { + Some(vmid) => { + match Self::lookup_vm_actor(vmid).await { + Some(actor_ref) => ctx.forward(&actor_ref, msg).await, + None => { + warn!(vm_id = %vmid, "VM actor not found for info lookup"); + ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) + } + } + }, None => { - warn!(vm_id = %vmid, "VM actor not found for info lookup"); - ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) + warn!("No vmid provided for Agent Actor GetVMInfo forwarding"); + ForwardedReply::from_ok(GetVMInfoReply { vmid: Ulid::nil(), config: None }) } } } diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 11a7591..91eadab 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -4,7 +4,7 @@ pub mod networking; mod state; mod util; use kameo::actor::Spawn; -use odorobo_shared::connect_to_swarm; +use odorobo_shared::{connect_to_swarm, actor_names::AGENT}; use stable_eyre::Result; // use odorobo_shared:: use crate::actor::AgentActor; @@ -35,7 +35,7 @@ async fn main() -> Result<()> { tracing::info!(?local_peer_id, "Peer ID"); let actor_ref = AgentActor::spawn(()); - actor_ref.register("agent").await?; + actor_ref.register(AGENT).await?; actor_ref.wait_for_shutdown().await; diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index ebd2ca1..2313b3f 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -7,9 +7,11 @@ use odorobo_agent::actor::AgentActor; use odorobo_agent::state::provisioning::actor::VMActor; use odorobo_shared::actor_cache::ActorCache; use odorobo_shared::actor_cache::ActorCacheUpdater; +use odorobo_shared::actor_names::VM; use odorobo_shared::messages::vm::*; use odorobo_shared::messages::agent::*; use odorobo_shared::messages::{Ping, Pong}; +use odorobo_shared::actor_names::AGENT; use odorobo_shared::utils::vm_actor_id; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, warn}; @@ -17,7 +19,8 @@ use tracing::{info, warn}; #[derive(RemoteActor)] pub struct SchedulerActor { - pub agent_actor_cache: ActorCache + pub agent_actor_cache: ActorCache, + pub vm_actor_cache: ActorCache //pub vm_actor_cache: Arc>>, //pub vm_keepalive_task: Option>, @@ -91,7 +94,7 @@ pub struct CachedAgentActor { #[async_trait] impl ActorCacheUpdater for AgentActorCacheUpdater { async fn get_actor_refs(&self) -> Result>> { - let mut agent_actors_lookup = RemoteActorRef::::lookup_all("agent"); + let mut agent_actors_lookup = RemoteActorRef::::lookup_all(AGENT); let mut actor_ref_vec = Vec::new(); while let Some(agent_actor) = agent_actors_lookup.try_next().await? { @@ -115,6 +118,43 @@ impl ActorCacheUpdater for AgentActorCacheUpdater } +// todo: this code is really bad, and we should not have effectively two copies of ths same thing. +#[derive(Copy, Clone)] +struct VMActorCacheUpdater; + +#[derive(Debug, Clone)] +pub struct CachedVMActor { + pub actor_ref: RemoteActorRef, + pub metadata: GetVMInfoReply, +} + +#[async_trait] +impl ActorCacheUpdater for VMActorCacheUpdater { + async fn get_actor_refs(&self) -> Result>> { + let mut agent_actors_lookup = RemoteActorRef::::lookup_all(VM); + let mut actor_ref_vec = Vec::new(); + + while let Some(agent_actor) = agent_actors_lookup.try_next().await? { + actor_ref_vec.push(agent_actor); + } + + Ok(actor_ref_vec) + } + + async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result { + let output_actor_ref = match previous_value { + Some(value) => value.actor_ref, + _ => actor_ref.clone(), + }; + + Ok(CachedVMActor { + actor_ref: output_actor_ref, + metadata: actor_ref.ask(&GetVMInfo {vmid: None}).await? + }) + } +} + + impl Actor for SchedulerActor { type Args = (); @@ -126,7 +166,8 @@ impl Actor for SchedulerActor { info!("Actor started! Scheduler peer id: {peer_id}"); Ok(Self { - agent_actor_cache: ActorCache::new(actor_ref, AgentActorCacheUpdater)?, + agent_actor_cache: ActorCache::new(actor_ref.clone(), AgentActorCacheUpdater)?, + vm_actor_cache: ActorCache::new(actor_ref, VMActorCacheUpdater)? }) } @@ -143,11 +184,14 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - self.agent_actor_cache.info().await; + //self.agent_actor_cache.info().await; + self.vm_actor_cache.info().await; self.agent_actor_cache.on_link_died(id).await; + self.vm_actor_cache.on_link_died(id).await; - self.agent_actor_cache.info().await; + self.vm_actor_cache.info().await; + //self.agent_actor_cache.info().await; Ok(ControlFlow::Continue(())) } diff --git a/odorobo-shared/src/actor_names.rs b/odorobo-shared/src/actor_names.rs new file mode 100644 index 0000000..026395d --- /dev/null +++ b/odorobo-shared/src/actor_names.rs @@ -0,0 +1,4 @@ + + +pub static AGENT: &str = "agent"; +pub static VM: &str = "vm"; diff --git a/odorobo-shared/src/lib.rs b/odorobo-shared/src/lib.rs index ab20a5f..9fc969e 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo-shared/src/lib.rs @@ -2,6 +2,7 @@ pub mod error; pub mod messages; pub mod utils; pub mod actor_cache; +pub mod actor_names; use kameo::prelude::*; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; diff --git a/odorobo-shared/src/messages/vm.rs b/odorobo-shared/src/messages/vm.rs index 0b397d6..a1dd566 100644 --- a/odorobo-shared/src/messages/vm.rs +++ b/odorobo-shared/src/messages/vm.rs @@ -90,10 +90,10 @@ pub struct AgentListVMsReply { /// Get VM info #[derive(Serialize, Deserialize, Debug)] pub struct GetVMInfo { - pub vmid: Ulid, + pub vmid: Option, } -#[derive(Serialize, Deserialize, Reply, Debug)] +#[derive(Serialize, Deserialize, Reply, Debug, Clone)] pub struct GetVMInfoReply { pub vmid: Ulid, pub config: Option, From bdf6617835b1a4697e88ae9f69559db65415fa59 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 11:42:42 -0500 Subject: [PATCH 139/189] last few minor cleanup things Signed-off-by: Caleb Jones --- README.md | 2 +- odorobo-agent/src/actor/mod.rs | 2 +- odorobo-manager/src/actors/scheduler_actor.rs | 3 ++- odorobo-shared/src/actor_names.rs | 2 -- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bdd8501..49186e9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Build the Agent binary with `cargo build --release` and run it on the host machi ```bash # Install dependencies (fedora) -sudo dnf in -y clang-devel nftables +sudo dnf in -y clang-devel nftables cloud-hypervisor # Build the Agent cargo build --release diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 5dcf547..9daf52a 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -417,7 +417,7 @@ impl Message for AgentActor { hostname: self.config.hostname.clone(), vcpus: self.vcpus, ram: self.memory, - vms: vec![Ulid::new()], // todo + vms: self.vms.keys().copied().collect(), } } } diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index 2313b3f..d615dff 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -259,8 +259,9 @@ impl Message for SchedulerActor { } } -/// this only gets data from the cache. +/// this only gets data from the cache from agents /// we may need a different message that actually forcibly runs/updates everything. +/// and/or messages that get data directly from the VMActors. impl Message for SchedulerActor { type Reply = Result; diff --git a/odorobo-shared/src/actor_names.rs b/odorobo-shared/src/actor_names.rs index 026395d..5fe74a5 100644 --- a/odorobo-shared/src/actor_names.rs +++ b/odorobo-shared/src/actor_names.rs @@ -1,4 +1,2 @@ - - pub static AGENT: &str = "agent"; pub static VM: &str = "vm"; From 4c60ac9ec5031ddef068dfa19f79e6f03ad94d6b Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 13:10:06 -0500 Subject: [PATCH 140/189] moved and minorly updated actor docs Signed-off-by: Caleb Jones --- actor_info.txt => docs/actors.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) rename actor_info.txt => docs/actors.md (78%) diff --git a/actor_info.txt b/docs/actors.md similarity index 78% rename from actor_info.txt rename to docs/actors.md index f19f826..db42506 100644 --- a/actor_info.txt +++ b/docs/actors.md @@ -1,4 +1,8 @@ -manager: +# Kameo Actors + +List of all kameo actors contained in these two crates. + +## odorobo-manager: http server (mvp) websocket server for serial terminal (mvp) @@ -13,8 +17,7 @@ scheduler_other_clusters (future roadmap) scheduler_to_other_manager (future roadmap) +# odorobo-agent: -agent: -server actor (mvp) (needs to be a trait so we can swap for other things in the future) +agent actor (mvp) (needs to be a trait so we can swap for other things in the future) vm actor (mvp) (needs to be a trait so we can swap for other things in the future - From fa0add4428feb5388c0a6d9ad0ae29e318c49698 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 13:17:38 -0500 Subject: [PATCH 141/189] add caching of vm config in agent actor and report resource usage data in AgentStatus Signed-off-by: Caleb Jones --- odorobo-agent/src/actor/mod.rs | 44 +++++++++++++++++++++++----- odorobo-shared/src/messages/agent.rs | 4 +++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 9daf52a..8585d3d 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -1,6 +1,7 @@ use crate::{networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor}; use ahash::AHashMap; use bytesize::ByteSize; +use cloud_hypervisor_client::models::VmConfig; use ipnet::Ipv4Net; use kameo::prelude::*; use odorobo_shared::{ @@ -16,12 +17,17 @@ use ulid::Ulid; use kameo::error::PanicError; +pub struct VMCacheData { + actor_ref: ActorRef, + config: VmConfig +} + #[derive(RemoteActor)] pub struct AgentActor { pub vcpus: u32, pub memory: ByteSize, pub config: Config, - pub vms: AHashMap>, + pub vms: AHashMap, // pub network_actor: ActorRef, } @@ -222,7 +228,7 @@ impl Actor for AgentActor { ) -> Result> { warn!("Linked actor {id:?} died with reason {reason:?}"); - self.vms.retain(|_, actor_ref| actor_ref.id() != id); + self.vms.retain(|_, vm| vm.actor_ref.id() != id); Ok(ControlFlow::Continue(())) } @@ -240,7 +246,13 @@ impl Message for AgentActor { let _ = actor_ref.register(vm_actor_id(vmid)).await; let _ = actor_ref.register(VM).await; - self.vms.insert(vmid, actor_ref.clone()); + self.vms.insert( + vmid, + VMCacheData { + actor_ref: actor_ref.clone(), + config: msg.config.clone() + } + ); info!(?vmid, "VM Spawned successfully"); CreateVMReply { @@ -263,7 +275,13 @@ impl Message for AgentActor { let _ = actor_ref.register(vm_actor_id(vmid)).await; let _ = actor_ref.register(VM).await; - self.vms.insert(vmid, actor_ref.clone()); + self.vms.insert( + vmid, + VMCacheData { + actor_ref: actor_ref.clone(), + config: msg.config.clone() + } + ); // now ask the VM actor to handle the migration receive actor_ref @@ -283,12 +301,12 @@ impl Message for AgentActor { _ctx: &mut Context, ) -> Self::Reply { match self.vms.remove(&msg.vmid) { - Some(actor_ref) => { - let res = actor_ref.tell(msg.clone()).await; + Some(cache_data) => { + let res = cache_data.actor_ref.tell(msg.clone()).await; if let Err(err) = res { // probably a bad way to do this warn!(vm_id = %msg.vmid, ?err, "failed to stop VM actor gracefully, killing"); - actor_ref.kill(); + cache_data.actor_ref.kill(); } } None => { @@ -413,11 +431,23 @@ impl Message for AgentActor { _ctx: &mut Context, ) -> Self::Reply { + let vcpus_used_by_vms = self.vms.iter() + .map(|(_, vm)| vm.config.cpus.as_ref().map(|cpu_config| cpu_config.boot_vcpus).unwrap_or(0)) + .reduce(|acc, cpus| acc + cpus) + .unwrap_or(0) as u32; + + let ram_used_by_vms = self.vms.iter() + .map(|(_, vm)| vm.config.memory.as_ref().map(|memory_config| memory_config.size).unwrap_or(0)) + .reduce(|acc, memory| acc + memory) + .unwrap_or(0) as u64; + AgentStatus { hostname: self.config.hostname.clone(), vcpus: self.vcpus, ram: self.memory, vms: self.vms.keys().copied().collect(), + used_vcpus: vcpus_used_by_vms - self.config.reserved_vcpus, + used_ram: ByteSize::b(ram_used_by_vms) } } } diff --git a/odorobo-shared/src/messages/agent.rs b/odorobo-shared/src/messages/agent.rs index c3e1e36..581761e 100644 --- a/odorobo-shared/src/messages/agent.rs +++ b/odorobo-shared/src/messages/agent.rs @@ -10,7 +10,11 @@ pub struct GetAgentStatus; #[derive(Serialize, Deserialize, Reply, Debug, Clone)] pub struct AgentStatus { pub hostname: String, + // todo: do we want to worry about things like CCX on epic chips? likely not necessary day 1 given we don't have epics. + /// Total number of vcpus before over-provisionment. pub vcpus: u32, pub ram: ByteSize, + pub used_vcpus: u32, + pub used_ram: ByteSize, pub vms: Vec } From d8dfbf10962a015e8c9ffc8436e946241157be74 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 14:08:34 -0500 Subject: [PATCH 142/189] improve vm scheduler algo Signed-off-by: Caleb Jones --- odorobo-agent/src/actor/mod.rs | 4 +- odorobo-manager/src/actors/scheduler_actor.rs | 80 +++++++++++++------ 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 8585d3d..28ec988 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -435,7 +435,7 @@ impl Message for AgentActor { .map(|(_, vm)| vm.config.cpus.as_ref().map(|cpu_config| cpu_config.boot_vcpus).unwrap_or(0)) .reduce(|acc, cpus| acc + cpus) .unwrap_or(0) as u32; - + let ram_used_by_vms = self.vms.iter() .map(|(_, vm)| vm.config.memory.as_ref().map(|memory_config| memory_config.size).unwrap_or(0)) .reduce(|acc, memory| acc + memory) @@ -446,7 +446,7 @@ impl Message for AgentActor { vcpus: self.vcpus, ram: self.memory, vms: self.vms.keys().copied().collect(), - used_vcpus: vcpus_used_by_vms - self.config.reserved_vcpus, + used_vcpus: vcpus_used_by_vms + self.config.reserved_vcpus, used_ram: ByteSize::b(ram_used_by_vms) } } diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index d615dff..f53afdd 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -13,6 +13,7 @@ use odorobo_shared::messages::agent::*; use odorobo_shared::messages::{Ping, Pong}; use odorobo_shared::actor_names::AGENT; use odorobo_shared::utils::vm_actor_id; +use stable_eyre::eyre::OptionExt; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::{info, warn}; @@ -26,6 +27,11 @@ pub struct SchedulerActor { //pub vm_keepalive_task: Option>, } +// todo: this might need to be a runtime thing but this makes it easy to write for now and could easily be switched out later. +static VCPU_OVERPROVISIONMENT_NUMERATOR: u32 = 2; +static VCPU_OVERPROVISIONMENT_DENOMINATOR: u32 = 1; + + impl SchedulerActor { async fn lookup_by_actor_id( &mut self, @@ -41,43 +47,69 @@ impl SchedulerActor { self.agent_actor_cache.lock_data_cache().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) } + /// current scheduling algo info: + /// this is vaguely based on https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ + /// when a vm is attempted to be scheduled, we loop through every agent and score it based on some rules + /// there are hard rules that will simply throw out an agent entirely. + /// otherwise, we take whatever the best agent we can find is. + /// + /// additionally, because caleb is way too performance brained, he used integer math for the entire scoring algorithm just so we didnt have to convert to floats. async fn schedule_agent( &mut self, msg: &CreateVM - ) -> RemoteActorRef { - + ) -> Result, Report> { let locked_agent_actor_cache = self.agent_actor_cache.lock_data_cache().await; - let agents: Vec<&CachedAgentActor> = locked_agent_actor_cache.values().collect(); + let mut best_agent = None; + let mut best_agent_score = 0u32; - warn!("randomly selecting agent"); - // random agent selection, so basically round robin - // todo: improve this with something that like actually schedules agents properly - let agent_index = rand::random_range(0..agents.len()); + // todo: this arguably could be done as map-reduce. is that better? + for agent in locked_agent_actor_cache.values() { + info!("{agent:?}"); - agents[agent_index].actor_ref.clone() - } + let mut agent_score = 0u32; + let agent_max_vcpus = agent.metadata.vcpus * VCPU_OVERPROVISIONMENT_NUMERATOR / VCPU_OVERPROVISIONMENT_DENOMINATOR; - /* - async fn update_vms( - &mut self - ) -> Result<(), Report> { - let mut vm_actors = RemoteActorRef::::lookup_all("agent"); - let mut get_vm_info_set = JoinSet::new(); - while let Some(vm_actor) = vm_actors.try_next().await? { - get_vm_info_set.spawn(async move { - tracing::trace!("AgentListVMs: vm_actor={:?}", vm_actor); + if agent.metadata.used_vcpus >= agent_max_vcpus { + continue; + } - vm_actor.ask(&GetVMInfo { vmid: ulid::Ulid::nil() }).await - }) - } - } + agent_score += agent.metadata.used_vcpus * 1024 / agent_max_vcpus; + + + // todo: add ram overprovisionment. not adding this to scheduler until it works on the hypervisor side. + let agent_max_ram = agent.metadata.ram; + + if agent.metadata.used_ram >= agent_max_ram { + continue; + } - */ + agent_score += (agent.metadata.used_ram.as_u64() * 1024 / agent_max_ram.as_u64()) as u32; + + // todo: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ + + + + // todo (future): possibly keep a percent of agents completely empty, to be able to be converted to dedis automatically. + // they would have their agent score set to 1, so they can be scheduled to if there is no other avaliable agents. + // rough pseudo code to implement this: + // if agent.metadata.vms.len() == 0 && hash(agent.config.hostname) % total_chance < threshold { + // agent_score = 1; + // } + + + if agent_score > best_agent_score { + best_agent = Some(agent.actor_ref.clone()); + best_agent_score = agent_score; + } + } + + best_agent.ok_or_eyre("No valid agents found.") + } } @@ -205,7 +237,7 @@ impl Message for SchedulerActor { async fn handle(&mut self, msg: CreateVM, _ctx: &mut Context) -> Self::Reply { loop { - let target_agent = self.schedule_agent(&msg).await; + let target_agent = self.schedule_agent(&msg).await?; match target_agent.ask(&msg).await { Ok(reply) => { From c2659486a558fbb0b93ba683bf0bf7100708f036 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 14:27:43 -0500 Subject: [PATCH 143/189] deal with clippy Signed-off-by: Caleb Jones --- odorobo-agent/src/actor/mod.rs | 8 ++++---- odorobo-shared/src/actor_cache/mod.rs | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index 28ec988..e3cd988 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -431,13 +431,13 @@ impl Message for AgentActor { _ctx: &mut Context, ) -> Self::Reply { - let vcpus_used_by_vms = self.vms.iter() - .map(|(_, vm)| vm.config.cpus.as_ref().map(|cpu_config| cpu_config.boot_vcpus).unwrap_or(0)) + let vcpus_used_by_vms = self.vms.values() + .map(|vm| vm.config.cpus.as_ref().map(|cpu_config| cpu_config.boot_vcpus).unwrap_or(0)) .reduce(|acc, cpus| acc + cpus) .unwrap_or(0) as u32; - let ram_used_by_vms = self.vms.iter() - .map(|(_, vm)| vm.config.memory.as_ref().map(|memory_config| memory_config.size).unwrap_or(0)) + let ram_used_by_vms = self.vms.values() + .map(|vm| vm.config.memory.as_ref().map(|memory_config| memory_config.size).unwrap_or(0)) .reduce(|acc, memory| acc + memory) .unwrap_or(0) as u64; diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index 0f72a94..3c218e2 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -77,7 +77,6 @@ impl Date: Sat, 25 Apr 2026 16:15:00 -0500 Subject: [PATCH 144/189] improve agent actor GetVMInfo forwarding Signed-off-by: Caleb Jones --- odorobo-agent/src/actor/mod.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo-agent/src/actor/mod.rs index e3cd988..d40f93d 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo-agent/src/actor/mod.rs @@ -354,22 +354,17 @@ impl Message for AgentActor { msg: GetVMInfo, ctx: &mut Context, ) -> Self::Reply { - // todo: caleb, i think this code can be cleaned up most likely, but im not sure what the best way to write it is unfortunately. - match msg.vmid { - Some(vmid) => { - match Self::lookup_vm_actor(vmid).await { - Some(actor_ref) => ctx.forward(&actor_ref, msg).await, - None => { - warn!(vm_id = %vmid, "VM actor not found for info lookup"); - ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) - } - } - }, - None => { - warn!("No vmid provided for Agent Actor GetVMInfo forwarding"); - ForwardedReply::from_ok(GetVMInfoReply { vmid: Ulid::nil(), config: None }) - } - } + let Some(vmid) = msg.vmid else { + warn!("No vmid provided for Agent Actor GetVMInfo forwarding"); + return ForwardedReply::from_ok(GetVMInfoReply { vmid: Ulid::nil(), config: None }) + }; + + let Some(actor_ref) = Self::lookup_vm_actor(vmid).await else { + warn!(vm_id = %vmid, "VM actor not found for info lookup"); + return ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) + }; + + ctx.forward(&actor_ref, msg).await } } From e1829b04b51865ef6f6b4152d04bda960c28a6d2 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 21:43:53 -0500 Subject: [PATCH 145/189] convert actor cache to use dashmap. Signed-off-by: Caleb Jones --- Cargo.lock | 15 ++++ Cargo.toml | 1 + odorobo-manager/src/actors/scheduler_actor.rs | 71 ++++++++------- odorobo-shared/Cargo.toml | 1 + odorobo-shared/src/actor_cache/mod.rs | 90 ++++++++----------- 5 files changed, 89 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6338ea5..bc3dba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1082,6 +1082,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -3066,6 +3080,7 @@ dependencies = [ "async-trait", "bytesize", "cloud-hypervisor-client", + "dashmap", "kameo", "libp2p", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0f17489..d3f6ed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ clap = { version = "4", features = ["derive", "env"] } ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} url = { version = "2.3", features = ["serde"] } async-trait = "0.1.89" +dashmap = "6.1.0" # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index f53afdd..b7ecfec 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -15,6 +15,7 @@ use odorobo_shared::actor_names::AGENT; use odorobo_shared::utils::vm_actor_id; use stable_eyre::eyre::OptionExt; use stable_eyre::{Report, Result, eyre::eyre}; +use tracing::info_span; use tracing::{info, warn}; @@ -37,14 +38,14 @@ impl SchedulerActor { &mut self, actor_id: &ActorId, ) -> Option> { - self.agent_actor_cache.lock_data_cache().await.get(actor_id).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.data_cache.get(actor_id).map(|data| data.actor_ref.clone()) } async fn lookup_by_hostname( &mut self, hostname: &str, ) -> Option> { - self.agent_actor_cache.lock_data_cache().await.values().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) + self.agent_actor_cache.data_cache.iter().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) } /// current scheduling algo info: @@ -58,55 +59,57 @@ impl SchedulerActor { &mut self, msg: &CreateVM ) -> Result, Report> { - let locked_agent_actor_cache = self.agent_actor_cache.lock_data_cache().await; - let mut best_agent = None; let mut best_agent_score = 0u32; // todo: this arguably could be done as map-reduce. is that better? - for agent in locked_agent_actor_cache.values() { - info!("{agent:?}"); + let span = info_span!("schedule_agent"); + span.in_scope(|| { + for agent in self.agent_actor_cache.data_cache.iter() { + let mut agent_score = 0u32; + + let agent_max_vcpus = agent.metadata.vcpus * VCPU_OVERPROVISIONMENT_NUMERATOR / VCPU_OVERPROVISIONMENT_DENOMINATOR; - let mut agent_score = 0u32; - let agent_max_vcpus = agent.metadata.vcpus * VCPU_OVERPROVISIONMENT_NUMERATOR / VCPU_OVERPROVISIONMENT_DENOMINATOR; + if agent.metadata.used_vcpus >= agent_max_vcpus { + continue; + } + agent_score += (agent_max_vcpus - agent.metadata.used_vcpus) * 1024 / agent_max_vcpus; - if agent.metadata.used_vcpus >= agent_max_vcpus { - continue; - } - agent_score += agent.metadata.used_vcpus * 1024 / agent_max_vcpus; + // todo: add ram overprovisionment. not adding this to scheduler until it works on the hypervisor side. + let agent_max_ram = agent.metadata.ram; + if agent.metadata.used_ram >= agent_max_ram { + continue; + } - // todo: add ram overprovisionment. not adding this to scheduler until it works on the hypervisor side. - let agent_max_ram = agent.metadata.ram; + agent_score += ((agent_max_ram.as_u64() - agent.metadata.used_ram.as_u64()) * 1024 / agent_max_ram.as_u64()) as u32; - if agent.metadata.used_ram >= agent_max_ram { - continue; - } - agent_score += (agent.metadata.used_ram.as_u64() * 1024 / agent_max_ram.as_u64()) as u32; + // todo: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ - // todo: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ + // todo (future): possibly keep a percent of agents completely empty, to be able to be converted to dedis automatically. + // they would have their agent score set to 1, so they can be scheduled to if there is no other avaliable agents. + // rough pseudo code to implement this: + // if agent.metadata.vms.len() == 0 && hash(agent.config.hostname) % total_chance < threshold { + // agent_score = 1; + // } - // todo (future): possibly keep a percent of agents completely empty, to be able to be converted to dedis automatically. - // they would have their agent score set to 1, so they can be scheduled to if there is no other avaliable agents. - // rough pseudo code to implement this: - // if agent.metadata.vms.len() == 0 && hash(agent.config.hostname) % total_chance < threshold { - // agent_score = 1; - // } + info!(agent=?agent.value(), score=agent_score); - if agent_score > best_agent_score { - best_agent = Some(agent.actor_ref.clone()); - best_agent_score = agent_score; + if agent_score > best_agent_score { + best_agent = Some(agent.actor_ref.clone()); + best_agent_score = agent_score; + } } - } + }); best_agent.ok_or_eyre("No valid agents found.") } @@ -217,12 +220,12 @@ impl Actor for SchedulerActor { }; //self.agent_actor_cache.info().await; - self.vm_actor_cache.info().await; + info!(vm_actor_cache=?self.vm_actor_cache.data_cache, "vm actor cache"); self.agent_actor_cache.on_link_died(id).await; self.vm_actor_cache.on_link_died(id).await; - self.vm_actor_cache.info().await; + info!(vm_actor_cache=?self.vm_actor_cache.data_cache, "vm actor cache"); //self.agent_actor_cache.info().await; Ok(ControlFlow::Continue(())) @@ -262,7 +265,7 @@ impl Message for SchedulerActor { _ctx: &mut Context, ) -> Self::Reply { let vm = RemoteActorRef::::lookup(vm_actor_id(msg.vmid)).await?; - tracing::trace!("DeleteVM: vm={:?}", vm); + tracing::trace!(?vm, "DeleteVM"); if let Some(vm) = vm { vm.tell(&msg).send()?; Ok(DeleteVMReply) @@ -281,7 +284,7 @@ impl Message for SchedulerActor { _ctx: &mut Context, ) -> Self::Reply { let vm = RemoteActorRef::::lookup(vm_actor_id(msg.vmid)).await?; - tracing::trace!("ShutdownVM: vm={:?}", vm); + tracing::trace!(?vm, "ShutdownVM"); if let Some(vm) = vm { vm.tell(&msg).send()?; Ok(ShutdownVMReply) @@ -304,7 +307,7 @@ impl Message for SchedulerActor { ) -> Self::Reply { let mut vms = Vec::new(); - for agent in self.agent_actor_cache.lock_data_cache().await.values() { + for agent in self.agent_actor_cache.data_cache.iter() { vms.extend_from_slice(agent.metadata.vms.as_slice()); } diff --git a/odorobo-shared/Cargo.toml b/odorobo-shared/Cargo.toml index 540b958..d6c0388 100644 --- a/odorobo-shared/Cargo.toml +++ b/odorobo-shared/Cargo.toml @@ -19,3 +19,4 @@ tracing = { workspace = true } bytesize = { workspace = true } async-trait = { workspace = true } ahash = { workspace = true } +dashmap = { workspace = true } \ No newline at end of file diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index 3c218e2..140f34a 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -2,10 +2,11 @@ use std::{marker::PhantomData, sync::Arc, time::Duration}; use ahash::AHashMap; use async_trait::async_trait; +use dashmap::DashMap; use kameo::{prelude::*}; use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; use stable_eyre::{Report, Result}; -use tracing::{info, trace}; +use tracing::{info, instrument, trace}; use std::fmt; @@ -31,26 +32,32 @@ pub trait ActorCacheUpdater> is going to result in contention and issues, when we have large numbers of VMs. -// -// The contention will be caused by the fact that we do a ping to check the status of each VM/agent once per second. -// If we are running 1000s of VMs in the future, one hashmap to store all of that is eventually going to have latency problems -// Especially since the hashmap is also used whenever a user wants to make a change or things change in the swarm. -// -// I am leaving it this way because I just wanted to get things working. We may need to change the way the data is stored in the future. -// My suggestions are either replacing it with a concurrent map, or using a RwLock. Either one might help, but I am not dealing with it now. #[derive(Debug)] pub struct ActorCache { parent_actor_ref: ActorRef, - data_cache: Arc>>, - keepalive_tasks: Arc>>>, + pub data_cache: Arc>, + keepalive_tasks: Arc>>, actor_finder: Option>, child_actor_type: PhantomData } +/* +// todo: get cappy to tell me how you are supposed to do this properly +pub async fn info(&self) { + let keepalives = self.keepalive_tasks.lock().await; + let cache = self.data_cache.lock().await; + + info!("agent actor cache data"); + for keepalive in keepalives.iter() { + info!("keepalive: {keepalive:?}"); + } + for data in cache.iter() { + info!("data: {data:?}"); + } +} + */ + // todo: impl Drop to automatically kill all the keepalive_tasks and the actor_finder task. impl ActorCache { @@ -59,8 +66,8 @@ impl ) -> Result { - let data_cache = Arc::new(Mutex::new(AHashMap::new())); - let keepalive_tasks = Arc::new(Mutex::new(AHashMap::new())); + let data_cache = Arc::new(DashMap::new()); + let keepalive_tasks = Arc::new(DashMap::new()); let actor_cache = ActorCache { parent_actor_ref: parent_actor_ref.clone(), @@ -85,20 +92,16 @@ impl MutexGuard<'_, AHashMap> { - self.data_cache.lock().await - } - fn start_actor_finder( &self, parent_actor_ref: ActorRef, @@ -110,7 +113,6 @@ impl, - keepalive_tasks: Arc>>>, - data_cache: Arc>>, + keepalive_tasks: Arc>>, + data_cache: Arc>, updater: impl ActorCacheUpdater ) -> Result<(), Report> { let actor_refs = updater.get_actor_refs().await?; - info!("actor_finder actor_refs: {actor_refs:?}"); + info!(?actor_refs, "running actor_finder"); for actor_ref in actor_refs { - tracing::trace!("UpdateAgents: agent_actor={:?}", actor_ref); - - let mut locked_agent_actors_keepalives = keepalive_tasks.lock().await; - - if !locked_agent_actors_keepalives.contains_key(&actor_ref.id()) { + if !keepalive_tasks.contains_key(&actor_ref.id()) { + trace!(?actor_ref, "starting updater_task"); parent_actor_ref.link_remote(&actor_ref).await?; @@ -152,7 +151,7 @@ impl, - data_cache: Arc>>, + data_cache: Arc>, updater: impl ActorCacheUpdater ) { let mut interval = tokio::time::interval(Duration::from_secs(1)); @@ -176,19 +176,13 @@ impl Date: Sat, 25 Apr 2026 21:50:14 -0500 Subject: [PATCH 146/189] final cleanup hopefully Signed-off-by: Caleb Jones --- odorobo-agent/src/main.rs | 1 - odorobo-manager/src/actors/scheduler_actor.rs | 3 -- odorobo-shared/src/actor_cache/mod.rs | 48 ++++++------------- odorobo-shared/src/utils.rs | 3 +- 4 files changed, 15 insertions(+), 40 deletions(-) diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs index 91eadab..ca16cb1 100644 --- a/odorobo-agent/src/main.rs +++ b/odorobo-agent/src/main.rs @@ -6,7 +6,6 @@ mod util; use kameo::actor::Spawn; use odorobo_shared::{connect_to_swarm, actor_names::AGENT}; use stable_eyre::Result; -// use odorobo_shared:: use crate::actor::AgentActor; #[tokio::main] diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo-manager/src/actors/scheduler_actor.rs index b7ecfec..40fef50 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo-manager/src/actors/scheduler_actor.rs @@ -23,9 +23,6 @@ use tracing::{info, warn}; pub struct SchedulerActor { pub agent_actor_cache: ActorCache, pub vm_actor_cache: ActorCache - - //pub vm_actor_cache: Arc>>, - //pub vm_keepalive_task: Option>, } // todo: this might need to be a runtime thing but this makes it easy to write for now and could easily be switched out later. diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo-shared/src/actor_cache/mod.rs index 140f34a..eb4b45b 100644 --- a/odorobo-shared/src/actor_cache/mod.rs +++ b/odorobo-shared/src/actor_cache/mod.rs @@ -1,27 +1,27 @@ use std::{marker::PhantomData, sync::Arc, time::Duration}; -use ahash::AHashMap; use async_trait::async_trait; use dashmap::DashMap; use kameo::{prelude::*}; -use tokio::{sync::{Mutex, MutexGuard}, task::JoinHandle}; +use tokio::task::JoinHandle; use stable_eyre::{Report, Result}; use tracing::{info, instrument, trace}; use std::fmt; -// future refactor TODO because I don't know how to do it now. -// The best way to make this would be that you crate a struct with #[derive(ActorCache)] -// and then you impl ActorCache with setting the ChildActor and Data as types similar to https://github.com/tqwewe/kameo/blob/1d498c0566b613b9afe6d54965c4b191c84432e0/src/actor.rs#L122 -// you could then just implement these get_actor_ref and on_update methods during that. -// we also would likely want default methods that just let you lookup_all for a specific actor string. -// you would also likely want to change get_actor_refs to return an iterator during this if you are doing this anyway. -// the problem is to do this you need to write a derive macro and I have no clue how to do that. -// and learning that now is not something i should spend tiem doing. -// so unfortunately instead I have to use self inside of the ActorCacheUpdater trait to make it work. -// Which I hate -// this would also make it where we dont need two structs, one for data and one for the update function hooks. -// I thnk it would also likely make a lot of the generic types simpler since hopefully their trait bounds would only be in one place. +// TODO: refactor to use derive macro, but I (caleb) don't know how to write a derive macro. +// +// The best way to write this would be using a derive similar to kameo +// so you would create a struct with #[derive(ActorCache)]. +// Then you would set the types and then write get_actor_refs and on_update. +// This would combine everything into one struct and make it a lot easier to work with. +// +// similar to: https://github.com/tqwewe/kameo/blob/1d498c0566b613b9afe6d54965c4b191c84432e0/src/actor.rs#L122 +// +// +// other things we might want: +// - a default get_actor_refs that just finds all actor_refs with a specific actor string. +// - change get_actor_refs to use an iterator #[async_trait] @@ -42,22 +42,6 @@ pub struct ActorCache } -/* -// todo: get cappy to tell me how you are supposed to do this properly -pub async fn info(&self) { - let keepalives = self.keepalive_tasks.lock().await; - let cache = self.data_cache.lock().await; - - info!("agent actor cache data"); - for keepalive in keepalives.iter() { - info!("keepalive: {keepalive:?}"); - } - for data in cache.iter() { - info!("data: {data:?}"); - } -} - */ - // todo: impl Drop to automatically kill all the keepalive_tasks and the actor_finder task. impl ActorCache { @@ -88,8 +72,6 @@ impl) -> EnvFilter { From 07be19451bb848528cd917cedbb955a77715ed86 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:11:37 -0500 Subject: [PATCH 147/189] merge crates Signed-off-by: Caleb Jones --- Cargo.lock | 116 +---------- Cargo.toml | 16 +- README.md | 24 +-- odorobo-agent/src/lib.rs | 5 - odorobo-agent/src/main.rs | 42 ---- odorobo-agent/src/util.rs | 12 -- odorobo-manager/Cargo.toml | 42 ---- odorobo-manager/src/lib.rs | 2 - odorobo-manager/src/main.rs | 50 ----- odorobo-shared/Cargo.toml | 22 --- odorobo-shared/src/actor_names.rs | 2 - odorobo-shared/src/error.rs | 7 - odorobo-shared/src/messages/ip_management.rs | 0 odorobo-shared/src/utils.rs | 52 ----- {odorobo-agent => odorobo}/Cargo.toml | 25 ++- {odorobo-agent => odorobo}/config.json | 1 + .../src/actors/agent_actor.rs | 186 +----------------- .../src/actors/http_actor.rs | 8 +- .../src/actors/ip_management_actor.rs | 0 .../src/actors/mod.rs | 3 +- .../src/actors/scheduler_actor.rs | 22 +-- .../actors/serial_terminal_websocket_actor.rs | 0 .../src/actors/storage_actor.rs | 0 .../src/api => odorobo/src/ch_api}/ch.rs | 0 .../src/api => odorobo/src/ch_api}/console.rs | 0 .../src/api => odorobo/src/ch_api}/error.rs | 0 .../src/api => odorobo/src/ch_api}/mod.rs | 0 .../src/api => odorobo/src/ch_api}/vm.rs | 0 odorobo/src/config.rs | 177 +++++++++++++++++ .../src/api => odorobo/src/http_api}/mod.rs | 0 .../src/api => odorobo/src/http_api}/nodes.rs | 2 +- .../src/api => odorobo/src/http_api}/types.rs | 0 .../src/api => odorobo/src/http_api}/vms.rs | 15 +- .../api => odorobo/src/http_api}/volumes.rs | 2 +- odorobo/src/main.rs | 56 ++++++ .../src/messages/agent.rs | 0 .../src/messages/debug.rs | 0 .../src/messages/mod.rs | 0 .../src/messages/vm.rs | 0 .../src/networking/actor.rs | 2 +- .../src/networking/messages.rs | 0 .../src/networking/mod.rs | 0 {odorobo-agent => odorobo}/src/state/api.rs | 0 .../src/state/devices.rs | 0 .../src/state/instance.rs | 0 {odorobo-agent => odorobo}/src/state/mod.rs | 0 .../src/state/provisioning/actor/mod.rs | 2 +- .../src/state/provisioning/hooks/machined.rs | 2 +- .../src/state/provisioning/hooks/mod.rs | 0 .../state/provisioning/hooks/networking.rs | 0 .../src/state/provisioning/mod.rs | 0 .../src/state/transform/console.rs | 0 .../src/state/transform/mod.rs | 0 .../src/state/transform/networking.rs | 0 .../src/state/transform/path_verify.rs | 0 .../src/state/transform/storage/file.rs | 0 .../src/state/transform/storage/iscsi.rs | 0 .../src/state/transform/storage/mod.rs | 0 .../src/state/transform/storage/rbd.rs | 0 .../src/utils/actor_cache.rs | 0 odorobo/src/utils/actor_names.rs | 11 ++ .../src/lib.rs => odorobo/src/utils/mod.rs | 80 +++++++- 62 files changed, 401 insertions(+), 585 deletions(-) delete mode 100644 odorobo-agent/src/lib.rs delete mode 100644 odorobo-agent/src/main.rs delete mode 100644 odorobo-agent/src/util.rs delete mode 100644 odorobo-manager/Cargo.toml delete mode 100644 odorobo-manager/src/lib.rs delete mode 100644 odorobo-manager/src/main.rs delete mode 100644 odorobo-shared/Cargo.toml delete mode 100644 odorobo-shared/src/actor_names.rs delete mode 100644 odorobo-shared/src/error.rs delete mode 100644 odorobo-shared/src/messages/ip_management.rs delete mode 100644 odorobo-shared/src/utils.rs rename {odorobo-agent => odorobo}/Cargo.toml (76%) rename {odorobo-agent => odorobo}/config.json (93%) rename odorobo-agent/src/actor/mod.rs => odorobo/src/actors/agent_actor.rs (58%) rename {odorobo-manager => odorobo}/src/actors/http_actor.rs (93%) rename {odorobo-manager => odorobo}/src/actors/ip_management_actor.rs (100%) rename {odorobo-manager => odorobo}/src/actors/mod.rs (72%) rename {odorobo-manager => odorobo}/src/actors/scheduler_actor.rs (95%) rename {odorobo-manager => odorobo}/src/actors/serial_terminal_websocket_actor.rs (100%) rename {odorobo-manager => odorobo}/src/actors/storage_actor.rs (100%) rename {odorobo-agent/src/api => odorobo/src/ch_api}/ch.rs (100%) rename {odorobo-agent/src/api => odorobo/src/ch_api}/console.rs (100%) rename {odorobo-agent/src/api => odorobo/src/ch_api}/error.rs (100%) rename {odorobo-agent/src/api => odorobo/src/ch_api}/mod.rs (100%) rename {odorobo-agent/src/api => odorobo/src/ch_api}/vm.rs (100%) create mode 100644 odorobo/src/config.rs rename {odorobo-manager/src/api => odorobo/src/http_api}/mod.rs (100%) rename {odorobo-manager/src/api => odorobo/src/http_api}/nodes.rs (92%) rename {odorobo-manager/src/api => odorobo/src/http_api}/types.rs (100%) rename {odorobo-manager/src/api => odorobo/src/http_api}/vms.rs (89%) rename {odorobo-manager/src/api => odorobo/src/http_api}/volumes.rs (95%) create mode 100644 odorobo/src/main.rs rename {odorobo-shared => odorobo}/src/messages/agent.rs (100%) rename {odorobo-shared => odorobo}/src/messages/debug.rs (100%) rename {odorobo-shared => odorobo}/src/messages/mod.rs (100%) rename {odorobo-shared => odorobo}/src/messages/vm.rs (100%) rename {odorobo-agent => odorobo}/src/networking/actor.rs (99%) rename {odorobo-agent => odorobo}/src/networking/messages.rs (100%) rename {odorobo-agent => odorobo}/src/networking/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/api.rs (100%) rename {odorobo-agent => odorobo}/src/state/devices.rs (100%) rename {odorobo-agent => odorobo}/src/state/instance.rs (100%) rename {odorobo-agent => odorobo}/src/state/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/provisioning/actor/mod.rs (99%) rename {odorobo-agent => odorobo}/src/state/provisioning/hooks/machined.rs (98%) rename {odorobo-agent => odorobo}/src/state/provisioning/hooks/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/provisioning/hooks/networking.rs (100%) rename {odorobo-agent => odorobo}/src/state/provisioning/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/console.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/networking.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/path_verify.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/storage/file.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/storage/iscsi.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/storage/mod.rs (100%) rename {odorobo-agent => odorobo}/src/state/transform/storage/rbd.rs (100%) rename odorobo-shared/src/actor_cache/mod.rs => odorobo/src/utils/actor_cache.rs (100%) create mode 100644 odorobo/src/utils/actor_names.rs rename odorobo-shared/src/lib.rs => odorobo/src/utils/mod.rs (66%) diff --git a/Cargo.lock b/Cargo.lock index bc3dba8..f26a26c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,7 +417,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", - "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -513,17 +512,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "axum_responses" version = "0.5.5" @@ -763,7 +751,6 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", "windows-link", ] @@ -1177,12 +1164,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "downcast-rs" version = "2.0.2" @@ -3002,16 +2983,19 @@ dependencies = [ ] [[package]] -name = "odorobo-agent" +name = "odorobo" version = "0.1.0" dependencies = [ "ahash", + "aide", "async-trait", "axum", "axum-extra 0.12.5", "axum_responses", "bytesize", + "clap", "cloud-hypervisor-client", + "dashmap", "futures-util", "http-body-util", "hyper", @@ -3023,9 +3007,11 @@ dependencies = [ "libc", "libp2p", "nftables", - "odorobo-shared", + "rand 0.10.1", "random-port", + "reqwest", "rtnetlink", + "schemars 0.9.0", "serde", "serde_json", "stable-eyre", @@ -3037,76 +3023,11 @@ dependencies = [ "tracing-subscriber", "ulid", "url", + "utoipa", "zbus", "zbus_systemd", ] -[[package]] -name = "odorobo-manager" -version = "0.1.0" -dependencies = [ - "ahash", - "aide", - "async-trait", - "axum", - "bytesize", - "clap", - "cloud-hypervisor-client", - "dotenvy", - "kameo", - "libp2p", - "odorobo-agent", - "odorobo-shared", - "optional_struct", - "rand 0.10.1", - "reqwest", - "schemars 0.9.0", - "serde", - "serde_json", - "stable-eyre", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", - "ulid", - "url", -] - -[[package]] -name = "odorobo-shared" -version = "0.1.0" -dependencies = [ - "ahash", - "async-trait", - "bytesize", - "cloud-hypervisor-client", - "dashmap", - "kameo", - "libp2p", - "serde", - "stable-eyre", - "thiserror 2.0.18", - "tokio", - "tracing", - "tracing-subscriber", - "ulid", - "utoipa", -] - -[[package]] -name = "odoroboctl" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "reqwest", - "serde", - "serde_json", - "stable-eyre", - "tokio", -] - [[package]] name = "oid-registry" version = "0.8.1" @@ -3144,27 +3065,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "optional_struct" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14199f59efce6ed2c5854f0abc725c32eedfbd02c6ef82c9733c726f3fc6dc91" -dependencies = [ - "optional_struct_macro", - "serde", -] - -[[package]] -name = "optional_struct_macro" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5eba042d9efe5e108e0df9ce2f85c540fc4f94f41c6821cbcf70ed47c1221da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ordered-stream" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index d3f6ed6..897cccd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [workspace] resolver = "3" -members = ["odorobo-agent", "odoroboctl", "odorobo-manager", "odorobo-shared"] +members = ["odorobo"] + [workspace.package] rust-version = "1.95.0" + [workspace.dependencies] stable-eyre = "0.2.2" tracing = "0.1" @@ -13,7 +15,7 @@ tracing-subscriber = { version = "0.3", features = [ ] } cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } serde_json = "1.0.149" -serde = "1.0" +serde = { version = "1.0.228", features = ["derive"] } axum = { version = "0.8.8", features = ["ws"] } thiserror = "2.0.18" axum-extra = "0.12" @@ -27,17 +29,17 @@ ulid = { version = "1.2", features = ["serde", "uuid"] } schemars = "0.9" dirs = "6.0.0" bytesize = { version = "2.3.1", features = ["serde"] } -odorobo-shared = { path = "odorobo-shared" } -odorobo-agent = { path = "odorobo-agent" } -odoroboctl = { path = "odoroboctl" } -odorobo-manager = { path = "odorobo-manager" } -dotenvy = "0.15" clap = { version = "4", features = ["derive", "env"] } ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} url = { version = "2.3", features = ["serde"] } async-trait = "0.1.89" dashmap = "6.1.0" +# our crates +odorobo = { path = "odorobo" } + + + # these are for performance optimizations, based on https://nnethercote.github.io/perf-book/build-configuration.html#optimization-level # these options can take a while to compile (up to 5-10 minutes), so its really only necessary for production binaries. # also look in .cargo/config.toml for info on microarchitecture optimizations. diff --git a/README.md b/README.md index 49186e9..e342d3a 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,14 @@ sudo dnf in -y clang-devel nftables cloud-hypervisor # Build the Agent cargo build --release -# create basic config -echo "{}" >> config.json +# Move to odorobo directory because the default config is there and the binary expects a config.json in the working directory. +cd odorobo # Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus -sudo ./target/release/odorobo-agent +sudo ./../target/release/odorobo ``` -You will now be required to run the manager process: -``` -# Build the Manager -cargo build --release -p odorobo-manager -# Run the Manager -./target/release/odorobo-manager -``` +It is recommended to set manager_enabled to false in the config file if you are going to run agents on multiple physical servers. You can run multiple managers for load balancing and HA, but it is not required. Install the CLI helper @@ -55,18 +49,12 @@ Install the CLI helper cargo install --path odoroboctl ``` -You can then use `odoroboctl` to directly interact with the Agent, for example to spawn a VM instance - -```bash -odoroboctl spawn my-vm -``` +You can then use `odoroboctl` to directly interact with the Manager, for example to spawn a VM instance Now apply the [Cloud Hypervisor VM spec](https://github.com/cloud-hypervisor/cloud-hypervisor/blob/main/docs/api.md#create-a-virtual-machine) to the instance, for example with a simple configuration that boots from a disk image ```bash -# the `--boot` flag additionally also tells Cloud Hypervisor to boot the VM after applying the configuration, otherwise it will stay -# in the "Created" state, requiring a separate `odoroboctl boot` call to start it. -odoroboctl create my-vm --boot ./my-vm.json +odoroboctl create vm.json ``` To connect directly on the host, connect to the VM's serial console socket in its runtime directory: diff --git a/odorobo-agent/src/lib.rs b/odorobo-agent/src/lib.rs deleted file mode 100644 index 4f7f4e7..0000000 --- a/odorobo-agent/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod actor; -mod api; -pub mod networking; -pub mod state; -mod util; diff --git a/odorobo-agent/src/main.rs b/odorobo-agent/src/main.rs deleted file mode 100644 index ca16cb1..0000000 --- a/odorobo-agent/src/main.rs +++ /dev/null @@ -1,42 +0,0 @@ -pub mod actor; -mod api; -pub mod networking; -mod state; -mod util; -use kameo::actor::Spawn; -use odorobo_shared::{connect_to_swarm, actor_names::AGENT}; -use stable_eyre::Result; -use crate::actor::AgentActor; - -#[tokio::main] -async fn main() -> Result<()> { - odorobo_shared::utils::init(Some("odorobo_agent"))?; - - tracing::info!("Starting odorobo-agent..."); - - // minimal axum server, debug socket - // - // todo: remove this, here to stub out dead code - // tokio::task::spawn(async { - // let listener = tokio::net::TcpListener::bind("0.0.0.0:8890").await?; - // let port = listener.local_addr()?.port(); - // let addrs: Vec = if_addrs::get_if_addrs()? - // .into_iter() - // .filter(|i| !i.is_loopback()) - // .map(|i| format!("http://{}:{}", i.ip(), port)) - // .collect(); - // tracing::info!(port, ?addrs, "Listening"); - // axum::serve(listener, api::router(port)).await?; - // Ok::<(), stable_eyre::Report>(()) - // }); - - let local_peer_id = connect_to_swarm().unwrap(); - tracing::info!(?local_peer_id, "Peer ID"); - - let actor_ref = AgentActor::spawn(()); - actor_ref.register(AGENT).await?; - - actor_ref.wait_for_shutdown().await; - - Ok(()) -} diff --git a/odorobo-agent/src/util.rs b/odorobo-agent/src/util.rs deleted file mode 100644 index 5b21398..0000000 --- a/odorobo-agent/src/util.rs +++ /dev/null @@ -1,12 +0,0 @@ -use stable_eyre::{Result, eyre::Context}; -use zbus::Connection; -/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name -pub fn systemd_instance_unit_name(vmid: &str) -> String { - format!("odorobo-ch@{vmid}.service") -} - -pub async fn zbus_system_connection() -> Result { - Connection::system() - .await - .wrap_err("Failed to connect to system D-Bus") -} diff --git a/odorobo-manager/Cargo.toml b/odorobo-manager/Cargo.toml deleted file mode 100644 index 709d709..0000000 --- a/odorobo-manager/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "odorobo-manager" -version = "0.1.0" -edition = "2024" - -[dependencies] -kameo = { version = "0.19.2", features = ["remote"] } -axum = { version = "0.8.8", features = ["tracing", "macros"] } -cloud-hypervisor-client = { workspace = true } -aide = { version = "0.15.1", features = [ - "axum", - "axum-json", - "axum-extra", - "axum-tokio", - "axum-ws", - "swagger", - "macros", -] } -schemars.workspace = true -clap.workspace = true -bytesize.workspace = true -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -tokio = { workspace = true, features = [ - "full", -] } # TODO (june): tracing is only in tokio unstable? NOTE (caleb): I think that just refers to tokio logging tracing events, we can still make our own and look at other crate's events if necessary. -tower-http = { version = "0.6.8", features = ["trace"] } -dotenvy = { workspace = true } -tracing = { workspace = true } -optional_struct = "0.5.2" -tracing-subscriber = { workspace = true } -ulid = { workspace = true } -libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } -reqwest = { version = "0.13", features = ["json"] } -ahash = { version = "0.8.12", features = ["serde"] } -url = { workspace = true } -# utoipa = { version = "5.4.0", features = ["uuid"] } -odorobo-shared = { workspace = true } -stable-eyre = { workspace = true } -odorobo-agent = { workspace = true } -async-trait = { workspace = true } -rand = "0.10.1" diff --git a/odorobo-manager/src/lib.rs b/odorobo-manager/src/lib.rs deleted file mode 100644 index dfc5c83..0000000 --- a/odorobo-manager/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod api; -pub mod actors; \ No newline at end of file diff --git a/odorobo-manager/src/main.rs b/odorobo-manager/src/main.rs deleted file mode 100644 index 76b7756..0000000 --- a/odorobo-manager/src/main.rs +++ /dev/null @@ -1,50 +0,0 @@ -use clap::Parser; -use kameo::prelude::*; -use odorobo_manager::actors::{http_actor::HTTPActor, scheduler_actor::SchedulerActor}; -use odorobo_shared::connect_to_swarm; -use serde::{Deserialize, Serialize}; -use stable_eyre::Result; -use tracing::info; - -// A config we definitely need: what is the router ip. -// TODO: talk to katherine about exactly how they want this config, since they may or may not be doing some of this, and we dont know if they want a .json for this or .env or something else. -#[derive(Serialize, Deserialize, Debug, Parser)] -struct Config { - /// Comma-separated list of actors to enable. - #[clap( - env = "ODOROBO_ACTORS", - default_value = "api,scheduler", - value_delimiter = ',' - )] - enabled_actors: Vec, -} - -// #[derive(Serialize, Deserialize, Debug)] -// struct EnabledActorsConfig { -// /// http should probably almost always be true, but its a config just in case. -// http: bool, -// /// scheduler -// scheduler: bool, -// } - -#[tokio::main] -async fn main() -> Result<()> { - let _local_peer_id = connect_to_swarm()?; - - odorobo_shared::utils::init(Some("odorobo_manager"))?; - dotenvy::dotenv().ok(); - info!("Starting odorobo-manager"); - - let scheduler_actor = SchedulerActor::spawn(()); - let http_actor = HTTPActor::spawn(scheduler_actor.clone()); - - scheduler_actor.register("scheduler").await?; - http_actor.register("apiserver").await?; - - tokio::join!( - scheduler_actor.wait_for_shutdown(), - http_actor.wait_for_shutdown(), - ); - - Ok(()) -} diff --git a/odorobo-shared/Cargo.toml b/odorobo-shared/Cargo.toml deleted file mode 100644 index d6c0388..0000000 --- a/odorobo-shared/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "odorobo-shared" -version = "0.1.0" -edition = "2024" -license = "AGPL-3.0-or-later" - -[dependencies] -kameo = { workspace = true, features = ["remote"] } -tokio = { workspace = true, features = ["full"] } -libp2p = { workspace = true, features = ["yamux", "serde", "mdns", "kad"] } -serde = { workspace = true, features = ["derive"] } -utoipa = { version = "5.4.0", features=["uuid"] } -stable-eyre = { workspace = true } -thiserror = { workspace = true } -ulid = { workspace = true } -cloud-hypervisor-client = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter"] } -tracing = { workspace = true } -bytesize = { workspace = true } -async-trait = { workspace = true } -ahash = { workspace = true } -dashmap = { workspace = true } \ No newline at end of file diff --git a/odorobo-shared/src/actor_names.rs b/odorobo-shared/src/actor_names.rs deleted file mode 100644 index 5fe74a5..0000000 --- a/odorobo-shared/src/actor_names.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub static AGENT: &str = "agent"; -pub static VM: &str = "vm"; diff --git a/odorobo-shared/src/error.rs b/odorobo-shared/src/error.rs deleted file mode 100644 index 6653ef9..0000000 --- a/odorobo-shared/src/error.rs +++ /dev/null @@ -1,7 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum OdoroboError { - #[error("{0}")] - Report(#[from] stable_eyre::Report), -} diff --git a/odorobo-shared/src/messages/ip_management.rs b/odorobo-shared/src/messages/ip_management.rs deleted file mode 100644 index e69de29..0000000 diff --git a/odorobo-shared/src/utils.rs b/odorobo-shared/src/utils.rs deleted file mode 100644 index 7326adf..0000000 --- a/odorobo-shared/src/utils.rs +++ /dev/null @@ -1,52 +0,0 @@ -use stable_eyre::Result; -use tracing_subscriber::{EnvFilter, filter::LevelFilter}; -use ulid::Ulid; - -pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { - let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); - - let base = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .parse_lossy(&env); - - #[cfg(debug_assertions)] - let base = { - let base = if let Some(debug_target) = debug_target { - base.add_directive(format!("{debug_target}=trace").parse().unwrap()) - } else { - base - }; - - base.add_directive( - format!("{}=debug", env!("CARGO_PKG_NAME").replace('-', "_")) - .parse() - .unwrap(), - ) - }; - - base -} - -pub fn vm_actor_id(vmid: Ulid) -> String { - format!("vm:{}", vmid) -} - -pub fn init(debug_target: Option<&str>) -> Result<()> { - stable_eyre::install()?; - let fmt = tracing_subscriber::fmt().with_env_filter(env_filter(debug_target)); - #[cfg(debug_assertions)] - let fmt = { - fmt.pretty() - .with_file(true) - .with_line_number(true) - .with_ansi(true) - }; - - fmt.init(); - - Ok(()) -} - -pub fn init_default() -> Result<()> { - init(None) -} diff --git a/odorobo-agent/Cargo.toml b/odorobo/Cargo.toml similarity index 76% rename from odorobo-agent/Cargo.toml rename to odorobo/Cargo.toml index bf339c4..966c887 100644 --- a/odorobo-agent/Cargo.toml +++ b/odorobo/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "odorobo-agent" +name = "odorobo" version = "0.1.0" edition = "2024" license = "AGPL-3.0-or-later" @@ -17,7 +17,7 @@ hyper-util = { version = "0.1.20", features = [ "http1", "http2", ] } -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true } serde_json = { workspace = true } hyperlocal = "0.9.1" tokio = { workspace = true, features = ["full"] } @@ -43,8 +43,27 @@ libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } ahash = { workspace = true } sysinfo = "0.38.4" bytesize = { workspace = true } -odorobo-shared = { workspace = true } ulid = { workspace = true } ipnet = { workspace = true } rtnetlink = "0.20.0" nftables = { version = "0.6.3", features = ["tokio"] } +utoipa = { version = "5.4.0", features=["uuid"] } + + +aide = { version = "0.15.1", features = [ + "axum", + "axum-json", + "axum-extra", + "axum-tokio", + "axum-ws", + "swagger", + "macros", +] } + + +schemars = { workspace = true } +clap = { workspace = true } + +reqwest = { version = "0.13", features = ["json"] } +rand = "0.10.1" +dashmap = { workspace = true } diff --git a/odorobo-agent/config.json b/odorobo/config.json similarity index 93% rename from odorobo-agent/config.json rename to odorobo/config.json index a8017f3..a73bb14 100644 --- a/odorobo-agent/config.json +++ b/odorobo/config.json @@ -1,6 +1,7 @@ { "datacenter": "Dev", "region": "Dev", + "manager_enabled": true, "network": { "dhcp_config": { "range": ["10.10.100.100", "10.10.100.200"], diff --git a/odorobo-agent/src/actor/mod.rs b/odorobo/src/actors/agent_actor.rs similarity index 58% rename from odorobo-agent/src/actor/mod.rs rename to odorobo/src/actors/agent_actor.rs index d40f93d..932fea8 100644 --- a/odorobo-agent/src/actor/mod.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -1,16 +1,10 @@ -use crate::{networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor}; +use crate::{config::Config, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; use ahash::AHashMap; use bytesize::ByteSize; use cloud_hypervisor_client::models::VmConfig; -use ipnet::Ipv4Net; use kameo::prelude::*; -use odorobo_shared::{ - actor_names::VM, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, utils::vm_actor_id -}; -use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use std::ops::ControlFlow; -use std::{fs, net::Ipv4Addr}; use sysinfo::System; use tracing::{error, info, trace, warn}; use ulid::Ulid; @@ -31,145 +25,7 @@ pub struct AgentActor { // pub network_actor: ActorRef, } -/// Gets the system hostname -pub fn hostname() -> String { - System::host_name().unwrap_or("odorobo".into()) -} - -/// This was requested by katherine. Do not change without asking her. -pub fn default_reserved_vcpus() -> u32 { - 2 -} - -fn default_datacenter() -> String { - warn!("No datacenter specified, defaulting to Dev"); - - "Dev".into() -} - -fn default_region() -> String { - warn!("No region specified, defaulting to Local"); - "Local".into() -} - -fn default_bridge_name() -> String { - "vmbr0".into() -} - -fn default_subnet() -> Ipv4Net { - "10.0.0.0/24".parse().unwrap() -} - -fn default_gateway() -> Ipv4Addr { - "10.0.0.1".parse().unwrap() -} -/// Infers the default upstream interface from the system's default route -fn default_upstream_iface() -> String { - // ip route - let out = std::process::Command::new("ip") - .arg("route") - .output() - .unwrap(); - let output = String::from_utf8(out.stdout).unwrap(); - - let default_route = output.lines().find(|l| l.starts_with("default")).unwrap(); - let iface = default_route.split_whitespace().nth(4).unwrap(); - info!("inferring default upstream interface: {}", iface); - iface.into() -} - -/// DHCP server config -/// -/// config options for dnsmasq -/// -/// this configures what options -// --no-daemon -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct DhcpConfig { - pub range: (Ipv4Addr, Ipv4Addr), - pub subnet: Ipv4Net, - /// lease time for DHCP clients - /// - /// example: 12h, 6h, 30m - pub lease_time: String, -} - -// TODO: move config into a separate module -#[derive(Serialize, Deserialize, Default, Clone)] -pub struct NetworkConfig { - pub dhcp_config: Option, - pub network_mode: NetworkMode, -} - -/// L3 routing configuration for guests -#[derive(Serialize, Deserialize, Clone)] -pub enum NetworkMode { - /// Private guest bridge with host-side gateway and outbound NAT. - HostonlyNat { - #[serde(default = "default_bridge_name")] - bridge: String, - #[serde(default = "default_subnet")] - subnet: Ipv4Net, - #[serde(default = "default_gateway")] - gateway: Ipv4Addr, - #[serde(default = "default_upstream_iface")] - upstream_iface: String, - }, - /// Flat bridge mode for operator-managed uplinks. - /// - /// The agent should only ensure that the bridge exists, is up, and has the - /// configured host address on it. It should not automatically enslave a - /// physical uplink into the bridge. Operators are expected to attach the - /// upstream interface themselves and handle any host networking migration - /// required for their environment. - /// - /// Per-VM TAP devices can still be attached to this bridge in the same way - /// as NAT mode. - Bridged { - bridge: String, - subnet: Ipv4Net, - gateway: Ipv4Addr, - }, -} - -impl Default for NetworkMode { - fn default() -> Self { - Self::HostonlyNat { - bridge: default_bridge_name(), - subnet: default_subnet(), - gateway: default_gateway(), - upstream_iface: default_upstream_iface(), - } - } -} -// The infra team wants a config file on the box where they can set info specific for the box its on. -// TODO: Double check with infra team (katherine) if they want any other config on the box. -#[derive(Serialize, Deserialize, Default)] -pub struct Config { - /// The hostname of the agent. Defaults to the system hostname - /// if not specified in the config file. - #[serde(default = "hostname")] - pub hostname: String, - /// The datacenter the agent is running in. - #[serde(default = "default_datacenter")] - pub datacenter: String, - /// The region the agent is running in. - #[serde(default = "default_region")] - pub region: String, - /// The number of VCPUs reserved for the agent. Defaults to 2. - #[serde(default = "default_reserved_vcpus")] - pub reserved_vcpus: u32, - /// this is just arbitrary data that will be shown but does no config - /// Arbitrary labels that can be used - #[serde(default)] - pub labels: AHashMap, - /// Arbitrary annotations that can be used - #[serde(default)] - pub annotations: AHashMap, - #[serde(default)] - pub network: NetworkConfig, -} impl AgentActor { async fn lookup_vm_actor(vmid: Ulid) -> Option> { @@ -181,18 +37,18 @@ impl AgentActor { } impl Actor for AgentActor { - type Args = (); + type Args = Config; type Error = Report; - async fn on_start(_state: Self::Args, actor_ref: ActorRef) -> Result { - // TODO: ask infra team where they want this on the box - let file = fs::File::open("config.json").expect("file should open read only"); - let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); + async fn on_start(config: Self::Args, actor_ref: ActorRef) -> Result { + let peer_id = *actor_ref.id().peer_id().unwrap(); + + info!(?peer_id, "Agent Actor started!"); // spawn networking actor let network_actor: ActorRef = NetworkAgentActor::spawn_link(&actor_ref, config.network.clone()).await; - network_actor.register("network_actor").await?; + network_actor.register(NETWORK).await?; let sys = System::new_all(); @@ -446,31 +302,3 @@ impl Message for AgentActor { } } } - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_config_serialize() { - let config = Config { - network: NetworkConfig { - dhcp_config: Some(DhcpConfig { - range: (Ipv4Addr::new(10, 10, 1, 100), Ipv4Addr::new(10, 10, 1, 200)), - subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 1, 0), 24).unwrap(), - lease_time: "12h".to_string(), - }), - network_mode: NetworkMode::HostonlyNat { - bridge: "vmbr0".to_string(), - gateway: Ipv4Addr::new(10, 10, 100, 1), - subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 100, 0), 24).unwrap(), - upstream_iface: default_upstream_iface(), - }, - }, - ..Default::default() - }; - - let json = serde_json::to_string_pretty(&config).unwrap(); - // assert_eq!(json, ) - println!("{}", json); - } -} diff --git a/odorobo-manager/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs similarity index 93% rename from odorobo-manager/src/actors/http_actor.rs rename to odorobo/src/actors/http_actor.rs index 61b88fb..588bccc 100644 --- a/odorobo-manager/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -1,12 +1,12 @@ use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; -use odorobo_shared::messages::vm::{ +use crate::messages::vm::{ AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, ShutdownVMReply, }; use stable_eyre::{Report, Result}; -use crate::api::types::CreateVMRequest; +use crate::http_api::types::CreateVMRequest; use super::scheduler_actor::SchedulerActor; @@ -56,11 +56,11 @@ impl Actor for HTTPActor { ) -> Result { // run the HTTP API tokio::spawn(async move { - tracing::info!("Starting HTTP server on {EXTERNAL_HTTP_URL}"); + tracing::info!(?EXTERNAL_HTTP_ADDRESS, "Starting HTTP server"); let listener = tokio::net::TcpListener::bind(EXTERNAL_HTTP_ADDRESS) .await .unwrap(); - axum::serve(listener, crate::api::build(actor_ref)) + axum::serve(listener, crate::http_api::build(actor_ref)) .await .unwrap(); }); diff --git a/odorobo-manager/src/actors/ip_management_actor.rs b/odorobo/src/actors/ip_management_actor.rs similarity index 100% rename from odorobo-manager/src/actors/ip_management_actor.rs rename to odorobo/src/actors/ip_management_actor.rs diff --git a/odorobo-manager/src/actors/mod.rs b/odorobo/src/actors/mod.rs similarity index 72% rename from odorobo-manager/src/actors/mod.rs rename to odorobo/src/actors/mod.rs index 6ef1cb9..6282dc9 100644 --- a/odorobo-manager/src/actors/mod.rs +++ b/odorobo/src/actors/mod.rs @@ -1,5 +1,6 @@ +pub mod agent_actor; pub mod http_actor; pub mod ip_management_actor; pub mod scheduler_actor; pub mod serial_terminal_websocket_actor; -pub mod storage_actor; \ No newline at end of file +pub mod storage_actor; diff --git a/odorobo-manager/src/actors/scheduler_actor.rs b/odorobo/src/actors/scheduler_actor.rs similarity index 95% rename from odorobo-manager/src/actors/scheduler_actor.rs rename to odorobo/src/actors/scheduler_actor.rs index 40fef50..cf0c8ba 100644 --- a/odorobo-manager/src/actors/scheduler_actor.rs +++ b/odorobo/src/actors/scheduler_actor.rs @@ -3,16 +3,16 @@ use std::ops::ControlFlow; use async_trait::async_trait; use kameo::prelude::*; use libp2p::futures::TryStreamExt; -use odorobo_agent::actor::AgentActor; -use odorobo_agent::state::provisioning::actor::VMActor; -use odorobo_shared::actor_cache::ActorCache; -use odorobo_shared::actor_cache::ActorCacheUpdater; -use odorobo_shared::actor_names::VM; -use odorobo_shared::messages::vm::*; -use odorobo_shared::messages::agent::*; -use odorobo_shared::messages::{Ping, Pong}; -use odorobo_shared::actor_names::AGENT; -use odorobo_shared::utils::vm_actor_id; +use crate::actors::agent_actor::AgentActor; +use crate::state::provisioning::actor::VMActor; +use crate::utils::actor_cache::ActorCache; +use crate::utils::actor_cache::ActorCacheUpdater; +use crate::utils::actor_names::VM; +use crate::messages::vm::*; +use crate::messages::agent::*; +use crate::messages::{Ping, Pong}; +use crate::utils::actor_names::AGENT; +use crate::utils::actor_names::vm_actor_id; use stable_eyre::eyre::OptionExt; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::info_span; @@ -195,7 +195,7 @@ impl Actor for SchedulerActor { async fn on_start(_state: Self::Args, actor_ref: ActorRef) -> Result { let peer_id = *actor_ref.id().peer_id().unwrap(); - info!("Actor started! Scheduler peer id: {peer_id}"); + info!(?peer_id, "Scheduler Actor started!"); Ok(Self { agent_actor_cache: ActorCache::new(actor_ref.clone(), AgentActorCacheUpdater)?, diff --git a/odorobo-manager/src/actors/serial_terminal_websocket_actor.rs b/odorobo/src/actors/serial_terminal_websocket_actor.rs similarity index 100% rename from odorobo-manager/src/actors/serial_terminal_websocket_actor.rs rename to odorobo/src/actors/serial_terminal_websocket_actor.rs diff --git a/odorobo-manager/src/actors/storage_actor.rs b/odorobo/src/actors/storage_actor.rs similarity index 100% rename from odorobo-manager/src/actors/storage_actor.rs rename to odorobo/src/actors/storage_actor.rs diff --git a/odorobo-agent/src/api/ch.rs b/odorobo/src/ch_api/ch.rs similarity index 100% rename from odorobo-agent/src/api/ch.rs rename to odorobo/src/ch_api/ch.rs diff --git a/odorobo-agent/src/api/console.rs b/odorobo/src/ch_api/console.rs similarity index 100% rename from odorobo-agent/src/api/console.rs rename to odorobo/src/ch_api/console.rs diff --git a/odorobo-agent/src/api/error.rs b/odorobo/src/ch_api/error.rs similarity index 100% rename from odorobo-agent/src/api/error.rs rename to odorobo/src/ch_api/error.rs diff --git a/odorobo-agent/src/api/mod.rs b/odorobo/src/ch_api/mod.rs similarity index 100% rename from odorobo-agent/src/api/mod.rs rename to odorobo/src/ch_api/mod.rs diff --git a/odorobo-agent/src/api/vm.rs b/odorobo/src/ch_api/vm.rs similarity index 100% rename from odorobo-agent/src/api/vm.rs rename to odorobo/src/ch_api/vm.rs diff --git a/odorobo/src/config.rs b/odorobo/src/config.rs new file mode 100644 index 0000000..90581b7 --- /dev/null +++ b/odorobo/src/config.rs @@ -0,0 +1,177 @@ +use ahash::AHashMap; +use ipnet::Ipv4Net; +use serde::{Deserialize, Serialize}; +use std::{net::Ipv4Addr}; +use sysinfo::System; +use tracing::{ info, warn}; + +/// Gets the system hostname +pub fn hostname() -> String { + System::host_name().unwrap_or("odorobo".into()) +} + +/// This was requested by katherine. Do not change without asking her. +pub fn default_reserved_vcpus() -> u32 { + 2 +} + +fn default_datacenter() -> String { + warn!("No datacenter specified, defaulting to Dev"); + + "Dev".into() +} + +fn default_region() -> String { + warn!("No region specified, defaulting to Local"); + "Local".into() +} + +fn default_bridge_name() -> String { + "vmbr0".into() +} + +fn default_subnet() -> Ipv4Net { + "10.0.0.0/24".parse().unwrap() +} + +fn default_gateway() -> Ipv4Addr { + "10.0.0.1".parse().unwrap() +} +/// Infers the default upstream interface from the system's default route +fn default_upstream_iface() -> String { + // ip route + let out = std::process::Command::new("ip") + .arg("route") + .output() + .unwrap(); + let output = String::from_utf8(out.stdout).unwrap(); + + let default_route = output.lines().find(|l| l.starts_with("default")).unwrap(); + let iface = default_route.split_whitespace().nth(4).unwrap(); + info!("inferring default upstream interface: {}", iface); + iface.into() +} + +/// DHCP server config +/// +/// config options for dnsmasq +/// +/// this configures what options +// --no-daemon +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DhcpConfig { + pub range: (Ipv4Addr, Ipv4Addr), + pub subnet: Ipv4Net, + /// lease time for DHCP clients + /// + /// example: 12h, 6h, 30m + pub lease_time: String, +} + +// TODO: move config into a separate module +#[derive(Serialize, Deserialize, Default, Clone, Debug)] +pub struct NetworkConfig { + pub dhcp_config: Option, + pub network_mode: NetworkMode, +} + +/// L3 routing configuration for guests +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum NetworkMode { + /// Private guest bridge with host-side gateway and outbound NAT. + HostonlyNat { + #[serde(default = "default_bridge_name")] + bridge: String, + #[serde(default = "default_subnet")] + subnet: Ipv4Net, + #[serde(default = "default_gateway")] + gateway: Ipv4Addr, + #[serde(default = "default_upstream_iface")] + upstream_iface: String, + }, + /// Flat bridge mode for operator-managed uplinks. + /// + /// The agent should only ensure that the bridge exists, is up, and has the + /// configured host address on it. It should not automatically enslave a + /// physical uplink into the bridge. Operators are expected to attach the + /// upstream interface themselves and handle any host networking migration + /// required for their environment. + /// + /// Per-VM TAP devices can still be attached to this bridge in the same way + /// as NAT mode. + Bridged { + bridge: String, + subnet: Ipv4Net, + gateway: Ipv4Addr, + }, +} + +impl Default for NetworkMode { + fn default() -> Self { + Self::HostonlyNat { + bridge: default_bridge_name(), + subnet: default_subnet(), + gateway: default_gateway(), + upstream_iface: default_upstream_iface(), + } + } +} + +// The infra team wants a config file on the box where they can set info specific for the box its on. +// TODO: Double check with infra team (katherine) if they want any other config on the box. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +pub struct Config { + /// The hostname of the agent. Defaults to the system hostname + /// if not specified in the config file. + #[serde(default = "hostname")] + pub hostname: String, + /// The datacenter the agent is running in. + #[serde(default = "default_datacenter")] + pub datacenter: String, + /// The region the agent is running in. + #[serde(default = "default_region")] + pub region: String, + /// The number of VCPUs reserved for the agent. Defaults to 2. + #[serde(default = "default_reserved_vcpus")] + pub reserved_vcpus: u32, + /// this is just arbitrary data that will be shown but does no config + /// Arbitrary labels that can be used + #[serde(default)] + pub labels: AHashMap, + /// Arbitrary annotations that can be used + #[serde(default)] + pub annotations: AHashMap, + /// Is manager enabled + #[serde(default)] + pub manager_enabled: bool, + #[serde(default)] + pub network: NetworkConfig, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_config_serialize() { + let config = Config { + network: NetworkConfig { + dhcp_config: Some(DhcpConfig { + range: (Ipv4Addr::new(10, 10, 1, 100), Ipv4Addr::new(10, 10, 1, 200)), + subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 1, 0), 24).unwrap(), + lease_time: "12h".to_string(), + }), + network_mode: NetworkMode::HostonlyNat { + bridge: "vmbr0".to_string(), + gateway: Ipv4Addr::new(10, 10, 100, 1), + subnet: Ipv4Net::new(Ipv4Addr::new(10, 10, 100, 0), 24).unwrap(), + upstream_iface: default_upstream_iface(), + }, + }, + ..Default::default() + }; + + let json = serde_json::to_string_pretty(&config).unwrap(); + // assert_eq!(json, ) + println!("{}", json); + } +} diff --git a/odorobo-manager/src/api/mod.rs b/odorobo/src/http_api/mod.rs similarity index 100% rename from odorobo-manager/src/api/mod.rs rename to odorobo/src/http_api/mod.rs diff --git a/odorobo-manager/src/api/nodes.rs b/odorobo/src/http_api/nodes.rs similarity index 92% rename from odorobo-manager/src/api/nodes.rs rename to odorobo/src/http_api/nodes.rs index 81a6c8c..65f0634 100644 --- a/odorobo-manager/src/api/nodes.rs +++ b/odorobo/src/http_api/nodes.rs @@ -1,5 +1,5 @@ //! Compute node management API handlers. -use crate::{actors::http_actor::HTTPActor, api::types::Node}; +use crate::{actors::http_actor::HTTPActor, http_api::types::Node}; use aide::axum::{ ApiRouter, IntoApiResponse, routing::{get, put}, diff --git a/odorobo-manager/src/api/types.rs b/odorobo/src/http_api/types.rs similarity index 100% rename from odorobo-manager/src/api/types.rs rename to odorobo/src/http_api/types.rs diff --git a/odorobo-manager/src/api/vms.rs b/odorobo/src/http_api/vms.rs similarity index 89% rename from odorobo-manager/src/api/vms.rs rename to odorobo/src/http_api/vms.rs index cb2571a..f21f535 100644 --- a/odorobo-manager/src/api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -1,10 +1,9 @@ //! VM management API handlers. use crate::{ actors::http_actor::HTTPActor, - api::types::{ - CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VMInfo, VMListResponse, - VmId, - }, + http_api::types::{ + CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VMInfo, VMListResponse, VMStatus, VmId + }, messages::vm::CreateVM, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -15,7 +14,7 @@ use axum::{ extract::{Path, State}, }; use kameo::actor::ActorRef; -use odorobo_shared::messages::vm::{AgentListVMs, DeleteVM, ShutdownVM}; +use crate::messages::vm::{AgentListVMs, DeleteVM, ShutdownVM}; pub fn router() -> ApiRouter> { ApiRouter::new() @@ -58,7 +57,7 @@ async fn create_vm( Json(VMInfo { data: vm_data, node: None, - status: crate::api::types::VMStatus::Provisioning, + status: VMStatus::Provisioning, }) } @@ -67,7 +66,7 @@ async fn debug_create_vm( Json(request): Json, ) -> impl IntoApiResponse { let ulid = ulid::Ulid::new(); - let message = odorobo_shared::messages::vm::CreateVM { + let message = CreateVM { vmid: ulid, config: request.vm_config, }; @@ -75,7 +74,7 @@ async fn debug_create_vm( let _reply = state.ask(message).await.unwrap(); Json(VMInfo { - status: crate::api::types::VMStatus::Provisioning, + status: VMStatus::Provisioning, data: VMData { id: ulid, ..Default::default() diff --git a/odorobo-manager/src/api/volumes.rs b/odorobo/src/http_api/volumes.rs similarity index 95% rename from odorobo-manager/src/api/volumes.rs rename to odorobo/src/http_api/volumes.rs index 41f6e47..35c2cd7 100644 --- a/odorobo-manager/src/api/volumes.rs +++ b/odorobo/src/http_api/volumes.rs @@ -1,7 +1,7 @@ //! Volume management API handlers. use crate::{ actors::http_actor::HTTPActor, - api::types::{CreateVolumeRequest, VolumeId, VolumeInfo}, + http_api::types::{CreateVolumeRequest, VolumeId, VolumeInfo}, }; use aide::axum::{ ApiRouter, IntoApiResponse, diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs new file mode 100644 index 0000000..f9ba603 --- /dev/null +++ b/odorobo/src/main.rs @@ -0,0 +1,56 @@ +pub mod actors; +mod ch_api; +pub mod http_api; +pub mod networking; +mod state; +mod utils; +pub mod messages; +pub mod config; + +use std::fs; + +use kameo::actor::Spawn; +use stable_eyre::Result; + +use crate::actors::http_actor::HTTPActor; +use crate::actors::scheduler_actor::SchedulerActor; +use crate::config::Config; +use crate::utils::actor_names::{HTTP_API_SERVER, SCHEDULER}; +use crate::utils::{actor_names::AGENT, connect_to_swarm, init}; +use crate::actors::agent_actor::AgentActor; + +#[tokio::main] +async fn main() -> Result<()> { + // TODO: ask infra team where they want this on the box + let file = fs::File::open("config.json").expect("file should open read only"); + let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); + + init(Some("odorobo"))?; + + tracing::info!(?config, "Starting odorobo"); + + let local_peer_id = connect_to_swarm().unwrap(); + tracing::info!(?local_peer_id, "Peer ID"); + + + // start agents + + let agent_actor = AgentActor::spawn(config.clone()); + agent_actor.register(AGENT).await?; + + if config.manager_enabled { + let scheduler_actor = SchedulerActor::spawn(()); + let http_actor = HTTPActor::spawn(scheduler_actor.clone()); + + scheduler_actor.register(SCHEDULER).await?; + http_actor.register(HTTP_API_SERVER).await?; + + scheduler_actor.wait_for_shutdown().await; + http_actor.wait_for_shutdown().await; + } + + + agent_actor.wait_for_shutdown().await; + + Ok(()) +} diff --git a/odorobo-shared/src/messages/agent.rs b/odorobo/src/messages/agent.rs similarity index 100% rename from odorobo-shared/src/messages/agent.rs rename to odorobo/src/messages/agent.rs diff --git a/odorobo-shared/src/messages/debug.rs b/odorobo/src/messages/debug.rs similarity index 100% rename from odorobo-shared/src/messages/debug.rs rename to odorobo/src/messages/debug.rs diff --git a/odorobo-shared/src/messages/mod.rs b/odorobo/src/messages/mod.rs similarity index 100% rename from odorobo-shared/src/messages/mod.rs rename to odorobo/src/messages/mod.rs diff --git a/odorobo-shared/src/messages/vm.rs b/odorobo/src/messages/vm.rs similarity index 100% rename from odorobo-shared/src/messages/vm.rs rename to odorobo/src/messages/vm.rs diff --git a/odorobo-agent/src/networking/actor.rs b/odorobo/src/networking/actor.rs similarity index 99% rename from odorobo-agent/src/networking/actor.rs rename to odorobo/src/networking/actor.rs index 1080e45..ea6d98b 100644 --- a/odorobo-agent/src/networking/actor.rs +++ b/odorobo/src/networking/actor.rs @@ -1,4 +1,4 @@ -use crate::actor::{DhcpConfig, NetworkConfig, NetworkMode}; +use crate::config::{DhcpConfig, NetworkConfig, NetworkMode}; use crate::networking::messages::{AttachTap, DetachTap}; use futures_util::{StreamExt, TryStreamExt}; diff --git a/odorobo-agent/src/networking/messages.rs b/odorobo/src/networking/messages.rs similarity index 100% rename from odorobo-agent/src/networking/messages.rs rename to odorobo/src/networking/messages.rs diff --git a/odorobo-agent/src/networking/mod.rs b/odorobo/src/networking/mod.rs similarity index 100% rename from odorobo-agent/src/networking/mod.rs rename to odorobo/src/networking/mod.rs diff --git a/odorobo-agent/src/state/api.rs b/odorobo/src/state/api.rs similarity index 100% rename from odorobo-agent/src/state/api.rs rename to odorobo/src/state/api.rs diff --git a/odorobo-agent/src/state/devices.rs b/odorobo/src/state/devices.rs similarity index 100% rename from odorobo-agent/src/state/devices.rs rename to odorobo/src/state/devices.rs diff --git a/odorobo-agent/src/state/instance.rs b/odorobo/src/state/instance.rs similarity index 100% rename from odorobo-agent/src/state/instance.rs rename to odorobo/src/state/instance.rs diff --git a/odorobo-agent/src/state/mod.rs b/odorobo/src/state/mod.rs similarity index 100% rename from odorobo-agent/src/state/mod.rs rename to odorobo/src/state/mod.rs diff --git a/odorobo-agent/src/state/provisioning/actor/mod.rs b/odorobo/src/state/provisioning/actor/mod.rs similarity index 99% rename from odorobo-agent/src/state/provisioning/actor/mod.rs rename to odorobo/src/state/provisioning/actor/mod.rs index 36493d4..ad32cbf 100644 --- a/odorobo-agent/src/state/provisioning/actor/mod.rs +++ b/odorobo/src/state/provisioning/actor/mod.rs @@ -1,7 +1,7 @@ use crate::state::VMInstance; use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; -use odorobo_shared::messages::vm::{ +use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, ShutdownVM, }; diff --git a/odorobo-agent/src/state/provisioning/hooks/machined.rs b/odorobo/src/state/provisioning/hooks/machined.rs similarity index 98% rename from odorobo-agent/src/state/provisioning/hooks/machined.rs rename to odorobo/src/state/provisioning/hooks/machined.rs index e4a6bb4..a222858 100644 --- a/odorobo-agent/src/state/provisioning/hooks/machined.rs +++ b/odorobo/src/state/provisioning/hooks/machined.rs @@ -2,7 +2,7 @@ use crate::state::VMInstance; use crate::state::provisioning::hooks::ProvisioningHook; -use crate::util::zbus_system_connection; +use crate::utils::zbus_system_connection; use async_trait::async_trait; use cloud_hypervisor_client::models::VmInfo; use stable_eyre::eyre::Context; diff --git a/odorobo-agent/src/state/provisioning/hooks/mod.rs b/odorobo/src/state/provisioning/hooks/mod.rs similarity index 100% rename from odorobo-agent/src/state/provisioning/hooks/mod.rs rename to odorobo/src/state/provisioning/hooks/mod.rs diff --git a/odorobo-agent/src/state/provisioning/hooks/networking.rs b/odorobo/src/state/provisioning/hooks/networking.rs similarity index 100% rename from odorobo-agent/src/state/provisioning/hooks/networking.rs rename to odorobo/src/state/provisioning/hooks/networking.rs diff --git a/odorobo-agent/src/state/provisioning/mod.rs b/odorobo/src/state/provisioning/mod.rs similarity index 100% rename from odorobo-agent/src/state/provisioning/mod.rs rename to odorobo/src/state/provisioning/mod.rs diff --git a/odorobo-agent/src/state/transform/console.rs b/odorobo/src/state/transform/console.rs similarity index 100% rename from odorobo-agent/src/state/transform/console.rs rename to odorobo/src/state/transform/console.rs diff --git a/odorobo-agent/src/state/transform/mod.rs b/odorobo/src/state/transform/mod.rs similarity index 100% rename from odorobo-agent/src/state/transform/mod.rs rename to odorobo/src/state/transform/mod.rs diff --git a/odorobo-agent/src/state/transform/networking.rs b/odorobo/src/state/transform/networking.rs similarity index 100% rename from odorobo-agent/src/state/transform/networking.rs rename to odorobo/src/state/transform/networking.rs diff --git a/odorobo-agent/src/state/transform/path_verify.rs b/odorobo/src/state/transform/path_verify.rs similarity index 100% rename from odorobo-agent/src/state/transform/path_verify.rs rename to odorobo/src/state/transform/path_verify.rs diff --git a/odorobo-agent/src/state/transform/storage/file.rs b/odorobo/src/state/transform/storage/file.rs similarity index 100% rename from odorobo-agent/src/state/transform/storage/file.rs rename to odorobo/src/state/transform/storage/file.rs diff --git a/odorobo-agent/src/state/transform/storage/iscsi.rs b/odorobo/src/state/transform/storage/iscsi.rs similarity index 100% rename from odorobo-agent/src/state/transform/storage/iscsi.rs rename to odorobo/src/state/transform/storage/iscsi.rs diff --git a/odorobo-agent/src/state/transform/storage/mod.rs b/odorobo/src/state/transform/storage/mod.rs similarity index 100% rename from odorobo-agent/src/state/transform/storage/mod.rs rename to odorobo/src/state/transform/storage/mod.rs diff --git a/odorobo-agent/src/state/transform/storage/rbd.rs b/odorobo/src/state/transform/storage/rbd.rs similarity index 100% rename from odorobo-agent/src/state/transform/storage/rbd.rs rename to odorobo/src/state/transform/storage/rbd.rs diff --git a/odorobo-shared/src/actor_cache/mod.rs b/odorobo/src/utils/actor_cache.rs similarity index 100% rename from odorobo-shared/src/actor_cache/mod.rs rename to odorobo/src/utils/actor_cache.rs diff --git a/odorobo/src/utils/actor_names.rs b/odorobo/src/utils/actor_names.rs new file mode 100644 index 0000000..89a796f --- /dev/null +++ b/odorobo/src/utils/actor_names.rs @@ -0,0 +1,11 @@ +use ulid::Ulid; + +pub static AGENT: &str = "agent"; +pub static VM: &str = "vm"; +pub static SCHEDULER: &str = "scheduler"; +pub static HTTP_API_SERVER: &str = "http_api_server"; +pub static NETWORK: &str = "network_actor"; + +pub fn vm_actor_id(vmid: Ulid) -> String { + format!("vm:{}", vmid) +} diff --git a/odorobo-shared/src/lib.rs b/odorobo/src/utils/mod.rs similarity index 66% rename from odorobo-shared/src/lib.rs rename to odorobo/src/utils/mod.rs index 9fc969e..e1b35d8 100644 --- a/odorobo-shared/src/lib.rs +++ b/odorobo/src/utils/mod.rs @@ -1,15 +1,85 @@ -pub mod error; -pub mod messages; -pub mod utils; -pub mod actor_cache; pub mod actor_names; +pub mod actor_cache; + +use stable_eyre::{Result, eyre::Context}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; +use zbus::Connection; +use thiserror::Error; use kameo::prelude::*; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::{PeerId, mdns, noise, tcp, yamux}; -use stable_eyre::Result; use tracing::{debug, info, trace, warn}; +#[derive(Error, Debug)] +pub enum OdoroboError { + #[error("{0}")] + Report(#[from] stable_eyre::Report), +} + + + +pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { + let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); + + let base = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse_lossy(&env); + + #[cfg(debug_assertions)] + let base = { + let base = if let Some(debug_target) = debug_target { + base.add_directive(format!("{debug_target}=trace").parse().unwrap()) + } else { + base + }; + + base.add_directive( + format!("{}=debug", env!("CARGO_PKG_NAME").replace('-', "_")) + .parse() + .unwrap(), + ) + }; + + base +} + +pub fn init(debug_target: Option<&str>) -> Result<()> { + stable_eyre::install()?; + let fmt = tracing_subscriber::fmt().with_env_filter(env_filter(debug_target)); + #[cfg(debug_assertions)] + let fmt = { + fmt.pretty() + .with_file(true) + .with_line_number(true) + .with_ansi(true) + }; + + fmt.init(); + + Ok(()) +} + +pub fn init_default() -> Result<()> { + init(None) +} + + + +/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name +pub fn systemd_instance_unit_name(vmid: &str) -> String { + format!("odorobo-ch@{vmid}.service") +} + +pub async fn zbus_system_connection() -> Result { + Connection::system() + .await + .wrap_err("Failed to connect to system D-Bus") +} + + + #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { kameo: remote::Behaviour, From eeb1eb9d937072f1ef0c8d4bf2d63f0010db0dc2 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:40:10 -0500 Subject: [PATCH 148/189] update dependency versions Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- Cargo.lock | 867 +++++++++++++----------------------------- Cargo.toml | 29 +- odorobo/Cargo.toml | 79 ++-- odoroboctl/Cargo.toml | 7 +- 4 files changed, 301 insertions(+), 681 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f26a26c..1ed9fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,18 +220,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -244,20 +232,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - [[package]] name = "async-io" version = "2.6.0" @@ -276,70 +250,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -390,9 +300,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -400,9 +310,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -412,9 +322,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64 0.22.1", @@ -489,14 +399,13 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ "axum", "axum-core", "bytes", - "form_urlencoded", "futures-core", "futures-util", "http 1.4.0", @@ -504,9 +413,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "serde_core", - "serde_html_form", - "serde_path_to_error", "tower-layer", "tower-service", "tracing", @@ -594,9 +500,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake2" @@ -616,19 +522,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bs58" version = "0.5.1" @@ -679,9 +572,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -768,9 +661,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -790,9 +683,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -809,7 +702,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cloud-hypervisor-client" version = "0.4.0" -source = "git+https://github.com/FyraStack/cloud-hypervisor-client.git#60ef245a60e8ba393f623cf0d30c0c298f0127ff" +source = "git+https://github.com/FyraStack/cloud-hypervisor-client.git#d2ffda1034e7e98c8180f1540a5cb01657e20c8c" dependencies = [ "base64 0.7.0", "futures", @@ -880,18 +773,18 @@ checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" [[package]] name = "const-str" -version = "0.7.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" dependencies = [ "const-str-proc-macro", ] [[package]] name = "const-str-proc-macro" -version = "0.7.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c25c2a02ba19f2d4fd9f54d5f239f97c867deb7397763a9771edab63c44a4fa" +checksum = "1c7e7913ec01ed98b697e62f8d3fd63c86dc6cccaf983c7eebc64d0e563b0ad9" dependencies = [ "proc-macro2", "quote", @@ -924,15 +817,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1085,15 +969,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "data-encoding-macro" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -1101,9 +985,9 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", "syn", @@ -1153,6 +1037,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1227,12 +1132,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1245,27 +1144,6 @@ dependencies = [ "syn", ] -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1313,12 +1191,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1422,10 +1294,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", "futures-core", - "futures-io", - "parking", "pin-project-lite", ] @@ -1592,9 +1461,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -1654,7 +1523,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", + "rand 0.9.4", "ring", "socket2 0.5.10", "thiserror 2.0.18", @@ -1677,7 +1546,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.9.4", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -1761,9 +1630,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1776,7 +1645,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1784,15 +1652,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1864,12 +1731,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1877,9 +1745,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1890,9 +1758,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1904,15 +1772,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1924,15 +1792,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1976,16 +1844,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if-addrs" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "if-addrs" version = "0.15.0" @@ -2006,14 +1864,14 @@ dependencies = [ "core-foundation 0.9.4", "fnv", "futures", - "if-addrs 0.15.0", + "if-addrs", "ipnet", "log", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.28.0", "netlink-proto", "netlink-sys", - "rtnetlink", + "rtnetlink 0.20.0", "system-configuration", "tokio", "windows", @@ -2034,7 +1892,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.9.2", + "rand 0.9.4", "tokio", "url", "xmltree", @@ -2048,12 +1906,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2092,9 +1950,9 @@ dependencies = [ [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -2168,22 +2026,24 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "kameo" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c4af7638c67029fd6821d02813c3913c803784648725d4df4082c9b91d7cbb1" +checksum = "b1dfd134d7a2c6ec05ee696dcbf3f7a034bdb97ecc623e981014652dcd124d77" dependencies = [ "const-fnv1a-hash", - "const-str 0.7.1", + "const-str 1.1.0", "downcast-rs", "dyn-clone", "either", @@ -2200,9 +2060,9 @@ dependencies = [ [[package]] name = "kameo_macros" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13c324e2d8c8e126e63e66087448b4267e263e6cb8770c56d10a9d0d279d9e2" +checksum = "16c9002c9ecd16e1636f566c0bf62db48e70d86ed0d0a91b398955e883217a23" dependencies = [ "heck", "proc-macro2", @@ -2224,9 +2084,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libp2p" @@ -2300,7 +2160,7 @@ dependencies = [ "parking_lot", "pin-project", "quick-protobuf", - "rand 0.8.5", + "rand 0.8.6", "rw-stream-sink", "thiserror 2.0.18", "tracing", @@ -2347,7 +2207,7 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "rand 0.8.5", + "rand 0.8.6", "regex", "serde", "sha2", @@ -2366,7 +2226,7 @@ dependencies = [ "hkdf", "multihash", "quick-protobuf", - "rand 0.8.5", + "rand 0.8.6", "serde", "sha2", "thiserror 2.0.18", @@ -2392,7 +2252,7 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "rand 0.8.5", + "rand 0.8.6", "serde", "sha2", "smallvec", @@ -2414,7 +2274,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "socket2 0.5.10", "tokio", @@ -2451,7 +2311,7 @@ dependencies = [ "multiaddr", "multihash", "quick-protobuf", - "rand 0.8.5", + "rand 0.8.6", "snow", "static_assertions", "thiserror 2.0.18", @@ -2473,7 +2333,7 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", - "rand 0.8.5", + "rand 0.8.6", "ring", "rustls", "socket2 0.5.10", @@ -2495,7 +2355,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "rand 0.8.5", + "rand 0.8.6", "serde", "smallvec", "tracing", @@ -2516,7 +2376,7 @@ dependencies = [ "libp2p-identity", "libp2p-swarm-derive", "multistream-select", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "tokio", "tracing", @@ -2599,20 +2459,29 @@ dependencies = [ "yamux 0.13.10", ] +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "linkme" -version = "0.3.35" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" dependencies = [ "linkme-impl", ] [[package]] name = "linkme-impl" -version = "0.3.35" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" dependencies = [ "proc-macro2", "quote", @@ -2627,9 +2496,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2684,15 +2553,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -2716,9 +2576,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2775,11 +2635,11 @@ dependencies = [ [[package]] name = "multihash" -version = "0.19.3" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" dependencies = [ - "core2", + "no_std_io2", "serde", "unsigned-varint 0.8.0", ] @@ -2819,6 +2679,18 @@ dependencies = [ "netlink-packet-core", ] +[[package]] +name = "netlink-packet-route" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + [[package]] name = "netlink-proto" version = "0.12.0" @@ -2886,6 +2758,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2990,18 +2871,19 @@ dependencies = [ "aide", "async-trait", "axum", - "axum-extra 0.12.5", + "axum-extra 0.12.6", "axum_responses", "bytesize", "clap", "cloud-hypervisor-client", "dashmap", + "dirs", "futures-util", "http-body-util", "hyper", "hyper-util", "hyperlocal", - "if-addrs 0.13.4", + "if-addrs", "ipnet", "kameo", "libc", @@ -3010,7 +2892,7 @@ dependencies = [ "rand 0.10.1", "random-port", "reqwest", - "rtnetlink", + "rtnetlink 0.21.0", "schemars 0.9.0", "serde", "serde_json", @@ -3023,9 +2905,6 @@ dependencies = [ "tracing-subscriber", "ulid", "url", - "utoipa", - "zbus", - "zbus_systemd", ] [[package]] @@ -3066,14 +2945,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "ordered-stream" +name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking" @@ -3152,23 +3027,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -3224,9 +3082,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3256,15 +3114,6 @@ dependencies = [ "syn", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3350,7 +3199,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -3399,9 +3248,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3410,9 +3259,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3480,7 +3329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52b7d0e298a1b2f2f46c8d5da944c80ed1e5e6b032521cc44ee2b1dcbe2b94a" dependencies = [ "network-interface", - "rand 0.8.5", + "rand 0.8.6", "thiserror 1.0.69", ] @@ -3506,6 +3355,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -3644,7 +3504,25 @@ dependencies = [ "futures-util", "log", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rtnetlink" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc19f84f710fa2f337617f9bc0400260a94224bde7bae28fd8879f3771ca5784" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route 0.30.0", "netlink-proto", "netlink-sys", "nix", @@ -3697,9 +3575,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "aws-lc-rs", "once_cell", @@ -3724,9 +3602,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -3761,9 +3639,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -3911,9 +3789,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3956,19 +3834,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_html_form" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" -dependencies = [ - "form_urlencoded", - "indexmap", - "itoa", - "ryu", - "serde_core", -] - [[package]] name = "serde_json" version = "1.0.149" @@ -4269,19 +4134,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4364,9 +4216,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4389,9 +4241,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -4407,9 +4259,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4428,9 +4280,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -4451,36 +4303,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml_datetime" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.8+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow 1.0.0", -] - -[[package]] -name = "toml_parser" -version = "1.1.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" -dependencies = [ - "winnow 1.0.0", -] - [[package]] name = "tower" version = "0.5.3" @@ -4611,37 +4433,25 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", "http 1.4.0", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", - "utf-8", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uint" @@ -4661,7 +4471,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.2", + "rand 0.9.4", "serde", "uuid", "web-time", @@ -4720,12 +4530,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4738,35 +4542,11 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "utoipa" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" -dependencies = [ - "indexmap", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-gen" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "uuid", -] - [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4813,11 +4593,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4826,14 +4606,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -4844,23 +4624,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4868,9 +4644,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -4881,9 +4657,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -4924,9 +4700,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4944,9 +4720,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -5118,15 +4894,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -5340,24 +5107,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5367,6 +5116,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -5448,9 +5203,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x25519-dalek" @@ -5507,7 +5262,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "pin-project", - "rand 0.8.5", + "rand 0.8.6", "static_assertions", ] @@ -5522,7 +5277,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "pin-project", - "rand 0.9.2", + "rand 0.9.4", "static_assertions", "web-time", ] @@ -5538,9 +5293,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5549,9 +5304,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -5559,92 +5314,20 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tokio", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zbus_systemd" -version = "0.26000.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c8310483ff4a77492d04a90bef30f40938059b4a4b4f7e456dda335d816cb6" -dependencies = [ - "serde", - "zbus", -] - [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -5653,18 +5336,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -5694,9 +5377,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -5705,9 +5388,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5716,9 +5399,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -5730,43 +5413,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 0.7.15", -] diff --git a/Cargo.toml b/Cargo.toml index 897cccd..ce70611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,34 +6,19 @@ members = ["odorobo"] rust-version = "1.95.0" [workspace.dependencies] -stable-eyre = "0.2.2" +stable-eyre = "0.2" +clap = { version = "4", features = ["derive", "env"] } +tokio = { version = "1" } +reqwest = { version = "0.13", features = ["json"] } +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = [ "env-filter", "fmt", "json", ] } -cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } -serde_json = "1.0.149" -serde = { version = "1.0.228", features = ["derive"] } -axum = { version = "0.8.8", features = ["ws"] } -thiserror = "2.0.18" -axum-extra = "0.12" -tower-http = { version = "0.6", features = ["trace"] } -tokio = { version = "1" } -kameo = { version = "0.19.2", features = ["remote"] } -libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } -ahash = { version = "0.8.12", features = ["serde"] } -ulid = { version = "1.2", features = ["serde", "uuid"] } -# aide needs an older version of schemars -schemars = "0.9" -dirs = "6.0.0" -bytesize = { version = "2.3.1", features = ["serde"] } -clap = { version = "4", features = ["derive", "env"] } -ipnet = {version = "2.12.0", features = ["serde", "schemars08"]} -url = { version = "2.3", features = ["serde"] } -async-trait = "0.1.89" -dashmap = "6.1.0" + # our crates odorobo = { path = "odorobo" } diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index 966c887..f575cd4 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -6,51 +6,48 @@ license = "AGPL-3.0-or-later" [dependencies] stable-eyre = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +tokio = { workspace = true, features = ["full"] } +reqwest = { workspace = true, features = ["json"] } +serde_json = { workspace = true } +serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } -cloud-hypervisor-client = { workspace = true } -http-body-util = "0.1.3" -hyper = { version = "1.8.1", features = ["full"] } -hyper-util = { version = "0.1.20", features = [ + +http-body-util = "0.1" +hyper = { version = "1.9", features = ["full"] } +hyper-util = { version = "0.1", features = [ "client", "client-legacy", "http1", "http2", ] } -serde = { workspace = true } -serde_json = { workspace = true } -hyperlocal = "0.9.1" -tokio = { workspace = true, features = ["full"] } -axum = { workspace = true, features = ["ws"] } -axum_responses = "0.5.5" -axum-extra = { workspace = true, features = ["query"] } -thiserror = "2.0.18" -futures-util = "0.3.31" +hyperlocal = "0.9" +axum_responses = "0.5" +thiserror = "2.0" +futures-util = "0.3" libc = "0.2" -tower-http = { workspace = true, features = ["trace"] } -random-port = "0.1.1" -if-addrs = "0.13" -zbus = "5.14.0" -zbus_systemd = { version = "0.26000.0", features = [ - "machine1", - "systemd1", - "zbus-async-tokio", -] } -url = { version = "2.5.8", features = ["serde"] } -async-trait = { workspace = true } -kameo = { workspace = true } -libp2p = { version = "0.56.0", features = ["yamux", "serde", "mdns"] } -ahash = { workspace = true } -sysinfo = "0.38.4" -bytesize = { workspace = true } -ulid = { workspace = true } -ipnet = { workspace = true } -rtnetlink = "0.20.0" -nftables = { version = "0.6.3", features = ["tokio"] } -utoipa = { version = "5.4.0", features=["uuid"] } - +random-port = "0.1" +if-addrs = "0.15" +libp2p = { version = "0.56", features = ["yamux", "serde", "mdns"] } +sysinfo = "0.38" +rtnetlink = "0.21" +nftables = { version = "0.6", features = ["tokio"] } +rand = "0.10" +axum = { version = "0.8", features = ["ws"] } +axum-extra = "0.12" +tower-http = { version = "0.6", features = ["trace"] } +kameo = { version = "0.20", features = ["remote"] } +ahash = { version = "0.8", features = ["serde"] } +ulid = { version = "1.2", features = ["serde", "uuid"] } +dirs = "6" +bytesize = { version = "2.3", features = ["serde"] } +ipnet = {version = "2.12", features = ["serde", "schemars08"]} +url = { version = "2.5", features = ["serde"] } +async-trait = "0.1" +dashmap = "6.1" -aide = { version = "0.15.1", features = [ +aide = { version = "0.15", features = [ "axum", "axum-json", "axum-extra", @@ -59,11 +56,7 @@ aide = { version = "0.15.1", features = [ "swagger", "macros", ] } +# aide needs an older version of schemars +schemars = "0.9" - -schemars = { workspace = true } -clap = { workspace = true } - -reqwest = { version = "0.13", features = ["json"] } -rand = "0.10.1" -dashmap = { workspace = true } +cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } diff --git a/odoroboctl/Cargo.toml b/odoroboctl/Cargo.toml index 3e903bc..22be64c 100644 --- a/odoroboctl/Cargo.toml +++ b/odoroboctl/Cargo.toml @@ -6,10 +6,9 @@ license = "AGPL-3.0-or-later" [dependencies] stable-eyre = { workspace = true } -clap = { version = "4", features = ["derive", "env"] } +clap = { workspace = true, features = ["derive", "env"] } tokio = { workspace = true, features = ["full"] } -anyhow = "1" -reqwest = { version = "0.13.2", features = ["json"] } +reqwest = { workspace = true, features = ["json"] } serde_json = { workspace = true } serde = { workspace = true, features = ["derive"] } -chrono = { version = "0.4.44", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } From a28cf00a3064bce6b3674eafce0eade0cce41735 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:40:52 -0500 Subject: [PATCH 149/189] remove machined and zbus Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- .../src/state/provisioning/hooks/machined.rs | 94 ------------------- odorobo/src/state/provisioning/hooks/mod.rs | 2 - odorobo/src/utils/mod.rs | 16 +--- 3 files changed, 1 insertion(+), 111 deletions(-) delete mode 100644 odorobo/src/state/provisioning/hooks/machined.rs diff --git a/odorobo/src/state/provisioning/hooks/machined.rs b/odorobo/src/state/provisioning/hooks/machined.rs deleted file mode 100644 index a222858..0000000 --- a/odorobo/src/state/provisioning/hooks/machined.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! systemd-machined integration hook for odorobo agent - -use crate::state::VMInstance; -use crate::state::provisioning::hooks::ProvisioningHook; -use crate::utils::zbus_system_connection; -use async_trait::async_trait; -use cloud_hypervisor_client::models::VmInfo; -use stable_eyre::eyre::Context; -use stable_eyre::{Result, eyre::eyre}; -use zbus_systemd::machine1::ManagerProxy; - -async fn get_manager_proxy() -> Result> { - let connection = zbus_system_connection().await?; - ManagerProxy::new(&connection) - .await - .wrap_err("Failed to create systemd manager proxy") -} - -pub struct CHMachine { - vmid: String, -} - -impl TryFrom for VMInstance { - type Error = stable_eyre::Report; - - fn try_from(value: CHMachine) -> Result { - VMInstance::get(&value.vmid).ok_or_else(|| { - eyre!( - "Failed to get VMInstance for CHMachine with vmid {}", - value.vmid - ) - .wrap_err("Failed to convert CHMachine to VMInstance") - }) - } -} - -pub const SERVICE_CLASS: &str = "cloud-hypervisor"; - -pub struct CHMachineProvisioningHook; - -#[async_trait] -impl ProvisioningHook for CHMachineProvisioningHook { - async fn after_start(&self, vmid: &str, _config: &VmInfo, pid: i32) -> Result<()> { - if pid == 0 { - tracing::warn!( - vmid, - "Skipping systemd-machined registration: PID is 0 (service not yet active)" - ); - return Ok(()); - } - tracing::info!(vmid, pid, "Registering machine with systemd-machined"); - let runtime_dir = VMInstance::runtime_dir_for(vmid); - let res = async { - let manager = get_manager_proxy().await?; - manager - .register_machine( - vmid.to_string(), - Vec::new(), - SERVICE_CLASS.to_string(), - "vm".to_string(), - pid as u32, - runtime_dir.display().to_string(), - ) - .await - .wrap_err("Failed to register machine with systemd-machined") - } - .await; - - if let Err(e) = res { - tracing::error!(vmid, error = ?e, "Failed to register machine with systemd-machined"); - tracing::warn!(vmid, "Continuing without systemd-machined registration"); - } - - Ok(()) - } - - async fn before_stop(&self, vmid: &str, _config: &VmInfo) -> Result<()> { - tracing::info!(vmid, "Unregistering machine from systemd-machined"); - let res = async { - let manager = get_manager_proxy().await?; - manager - .unregister_machine(vmid.to_string()) - .await - .wrap_err("Failed to unregister machine from systemd-machined") - } - .await; - - if let Err(e) = res { - tracing::error!(vmid, error = ?e, "Failed to unregister machine from systemd-machined"); - } - - Ok(()) - } -} diff --git a/odorobo/src/state/provisioning/hooks/mod.rs b/odorobo/src/state/provisioning/hooks/mod.rs index f19b02c..9d6dda7 100644 --- a/odorobo/src/state/provisioning/hooks/mod.rs +++ b/odorobo/src/state/provisioning/hooks/mod.rs @@ -12,7 +12,6 @@ use cloud_hypervisor_client::models::{VmConfig, VmInfo}; use stable_eyre::Result; use tracing::info; -mod machined; mod networking; // Rust 1.75 does not support dyn async traits, we still need async_trait for this @@ -107,7 +106,6 @@ impl Default for HookManager { fn default() -> Self { Self { hooks: vec![ - Box::new(machined::CHMachineProvisioningHook), Box::new(networking::NetworkProvisioningHook), ], } diff --git a/odorobo/src/utils/mod.rs b/odorobo/src/utils/mod.rs index e1b35d8..55bffdb 100644 --- a/odorobo/src/utils/mod.rs +++ b/odorobo/src/utils/mod.rs @@ -1,10 +1,9 @@ pub mod actor_names; pub mod actor_cache; -use stable_eyre::{Result, eyre::Context}; +use stable_eyre::{Result}; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; -use zbus::Connection; use thiserror::Error; use kameo::prelude::*; use libp2p::futures::StreamExt; @@ -67,19 +66,6 @@ pub fn init_default() -> Result<()> { -/// template for systemd unit name for CH instances, where the instance ID is substituted into the unit name -pub fn systemd_instance_unit_name(vmid: &str) -> String { - format!("odorobo-ch@{vmid}.service") -} - -pub async fn zbus_system_connection() -> Result { - Connection::system() - .await - .wrap_err("Failed to connect to system D-Bus") -} - - - #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { kameo: remote::Behaviour, From f461a1abc476b6859f16c6935352d1511f4ed8db Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:42:03 -0500 Subject: [PATCH 150/189] improve scheduler actor cache logging Signed-off-by: Caleb Jones --- odorobo/src/actors/scheduler_actor.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/odorobo/src/actors/scheduler_actor.rs b/odorobo/src/actors/scheduler_actor.rs index cf0c8ba..3e97ab8 100644 --- a/odorobo/src/actors/scheduler_actor.rs +++ b/odorobo/src/actors/scheduler_actor.rs @@ -216,14 +216,10 @@ impl Actor for SchedulerActor { return Ok(ControlFlow::Break(ActorStopReason::Killed)); }; - //self.agent_actor_cache.info().await; - info!(vm_actor_cache=?self.vm_actor_cache.data_cache, "vm actor cache"); - self.agent_actor_cache.on_link_died(id).await; self.vm_actor_cache.on_link_died(id).await; - info!(vm_actor_cache=?self.vm_actor_cache.data_cache, "vm actor cache"); - //self.agent_actor_cache.info().await; + info!(vm_actor_cache=?self.agent_actor_cache.data_cache, agent_actor_cache=?self.vm_actor_cache.data_cache, "data caches post actor removal"); Ok(ControlFlow::Continue(())) } From b19919316f962868ddc99e35d8d2f127e7aca330 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:54:38 -0500 Subject: [PATCH 151/189] remove systemd references, update readme, improve config handling. Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- README.md | 10 +++--- odorobo/config.json | 1 - odorobo/src/config.rs | 14 ++++++-- odorobo/src/main.rs | 13 ++++--- systemd/odorobo-ch@.service | 35 ------------------- ...{odorobo-agent.service => odorobo.service} | 7 ++-- systemd/scripts/odorobo-cleanup | 7 ---- systemd/scripts/odorobo-postflight | 7 ---- systemd/scripts/odorobo-preflight | 10 ------ 9 files changed, 28 insertions(+), 76 deletions(-) delete mode 100644 systemd/odorobo-ch@.service rename systemd/{odorobo-agent.service => odorobo.service} (74%) delete mode 100644 systemd/scripts/odorobo-cleanup delete mode 100644 systemd/scripts/odorobo-postflight delete mode 100644 systemd/scripts/odorobo-preflight diff --git a/README.md b/README.md index e342d3a..c2f7e24 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,14 @@ sudo dnf in -y clang-devel nftables cloud-hypervisor # Build the Agent cargo build --release -# Move to odorobo directory because the default config is there and the binary expects a config.json in the working directory. -cd odorobo +# Run the Agent & Manager (requires write permissions to /run/odorobo) +sudo ./target/release/odorobo --manager-enabled=true # or set ODOROBO_MANAGER_ENABLED=true -# Run the Agent (requires write permissions to /run/odorobo, and access to systemd's system session bus -sudo ./../target/release/odorobo +# Run on other boxes +sudo ./target/release/odorobo ``` -It is recommended to set manager_enabled to false in the config file if you are going to run agents on multiple physical servers. You can run multiple managers for load balancing and HA, but it is not required. +You can run multiple managers for load balancing and HA, but it is not required. Install the CLI helper diff --git a/odorobo/config.json b/odorobo/config.json index a73bb14..a8017f3 100644 --- a/odorobo/config.json +++ b/odorobo/config.json @@ -1,7 +1,6 @@ { "datacenter": "Dev", "region": "Dev", - "manager_enabled": true, "network": { "dhcp_config": { "range": ["10.10.100.100", "10.10.100.200"], diff --git a/odorobo/src/config.rs b/odorobo/src/config.rs index 90581b7..8b261ea 100644 --- a/odorobo/src/config.rs +++ b/odorobo/src/config.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::{net::Ipv4Addr}; use sysinfo::System; use tracing::{ info, warn}; +use clap::Parser; /// Gets the system hostname pub fn hostname() -> String { @@ -117,6 +118,15 @@ impl Default for NetworkMode { } } +/// Additional runtime options for the agent that aren't applicable to a JSON config file +#[derive(Parser)] +pub struct CliConfig { + + /// Whether the manager should be enabled on this instance + #[clap(long, default_value = "false", env = "ODOROBO_MANAGER_ENABLED")] + pub manager_enabled: bool, +} + // The infra team wants a config file on the box where they can set info specific for the box its on. // TODO: Double check with infra team (katherine) if they want any other config on the box. #[derive(Serialize, Deserialize, Default, Debug, Clone)] @@ -141,9 +151,7 @@ pub struct Config { /// Arbitrary annotations that can be used #[serde(default)] pub annotations: AHashMap, - /// Is manager enabled - #[serde(default)] - pub manager_enabled: bool, + #[serde(default)] pub network: NetworkConfig, } diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index f9ba603..afacbb5 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -9,6 +9,7 @@ pub mod config; use std::fs; +use clap::Parser; use kameo::actor::Spawn; use stable_eyre::Result; @@ -21,24 +22,28 @@ use crate::actors::agent_actor::AgentActor; #[tokio::main] async fn main() -> Result<()> { + let cli_config = config::CliConfig::parse(); // TODO: ask infra team where they want this on the box - let file = fs::File::open("config.json").expect("file should open read only"); - let config: Config = serde_json::from_reader(file).expect("file should be proper JSON"); + let config: Config = if let Ok(file) = fs::File::open("config.json") { + serde_json::from_reader(file).expect("unable to parse config.json") + } else { + Config::default() + }; init(Some("odorobo"))?; tracing::info!(?config, "Starting odorobo"); + let local_peer_id = connect_to_swarm().unwrap(); tracing::info!(?local_peer_id, "Peer ID"); // start agents - let agent_actor = AgentActor::spawn(config.clone()); agent_actor.register(AGENT).await?; - if config.manager_enabled { + if cli_config.manager_enabled { let scheduler_actor = SchedulerActor::spawn(()); let http_actor = HTTPActor::spawn(scheduler_actor.clone()); diff --git a/systemd/odorobo-ch@.service b/systemd/odorobo-ch@.service deleted file mode 100644 index 7436bec..0000000 --- a/systemd/odorobo-ch@.service +++ /dev/null @@ -1,35 +0,0 @@ -[Unit] -Description=Odorobo Cloud Hypervisor VM (%i) -Documentation=https://www.cloudhypervisor.org/docs/ -After=network-online.target -Wants=network-online.target -#Requires=odorobo-agent.service - -# If this unit fails, don't take down other VMs -OnFailure=odorobo-ch-failed@%i.service - -[Service] -Type=simple -User=root -Group=root - -LogsDirectory=odorobo -LogsDirectoryMode=0750 -ConfigurationDirectory=odorobo/vms/%i -ConfigurationDirectoryMode=0750 - -ExecStartPre=/usr/local/libexec/odorobo-preflight %i -# the actual VM process -ExecStart=/usr/bin/cloud-hypervisor -v \ - --api-socket /run/odorobo/vms/%i/ch.sock - -ExecStartPost=/usr/local/libexec/odorobo-postflight %i -ExecStop=/usr/local/libexec/odorobo-cleanup %i - -Restart=on-failure -RestartSec=3s -RestartPreventExitStatus=0 -TimeoutStopSec=30s - -[Install] -WantedBy=multi-user.target diff --git a/systemd/odorobo-agent.service b/systemd/odorobo.service similarity index 74% rename from systemd/odorobo-agent.service rename to systemd/odorobo.service index be768ea..c4fcbce 100644 --- a/systemd/odorobo-agent.service +++ b/systemd/odorobo.service @@ -1,17 +1,16 @@ [Unit] -Description=Odorobo Agent +Description=Odorobo Service Documentation=https://github.com/FyraStack/odorobo After=network-online.target Wants=network-online.target -#Requires=odorobo-agent.service [Service] Type=simple User=root Group=root - -ExecStart=/usr/bin/odorobo-agent +EnvironmentFile=/etc/odorobo/config.env +ExecStart=/usr/bin/odorobo Restart=always diff --git a/systemd/scripts/odorobo-cleanup b/systemd/scripts/odorobo-cleanup deleted file mode 100644 index bda8065..0000000 --- a/systemd/scripts/odorobo-cleanup +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -VMID=$1 -if [ -z "$VMID" ]; then - echo "Usage: $0 " - exit 1 -fi diff --git a/systemd/scripts/odorobo-postflight b/systemd/scripts/odorobo-postflight deleted file mode 100644 index 1251db0..0000000 --- a/systemd/scripts/odorobo-postflight +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -VMID=$1 -if [ -z "$VMID" ]; then - echo "Usage: $0 " - exit 1 -fi \ No newline at end of file diff --git a/systemd/scripts/odorobo-preflight b/systemd/scripts/odorobo-preflight deleted file mode 100644 index 69725c8..0000000 --- a/systemd/scripts/odorobo-preflight +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -VMID=$1 -if [ -z "$VMID" ]; then - echo "Usage: $0 " - exit 1 -fi - -mkdir -p /run/odorobo/vms/$VMID -chmod -R 775 /run/odorobo/vms/$VMID \ No newline at end of file From 4e41824b041b7d5006f5b70aa33849719386bfd3 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 14:57:39 -0500 Subject: [PATCH 152/189] remove unused http url const Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- odorobo/src/actors/http_actor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/odorobo/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs index 588bccc..cc13201 100644 --- a/odorobo/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -11,7 +11,6 @@ use crate::http_api::types::CreateVMRequest; use super::scheduler_actor::SchedulerActor; const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; -const EXTERNAL_HTTP_URL: &str = "http://localhost:3000"; // TODO: mak /// HTTP REST API service #[derive(RemoteActor)] pub struct HTTPActor { From 72869caa1046745207e9949fed8002bcad9dddbe Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 15:52:52 -0500 Subject: [PATCH 153/189] improve http error handling and move types file. Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- Cargo.lock | 27 +++++++++++++++ odorobo/Cargo.toml | 5 +++ odorobo/src/actors/agent_actor.rs | 4 ++- odorobo/src/actors/http_actor.rs | 2 +- odorobo/src/http_api/mod.rs | 13 ++----- odorobo/src/http_api/nodes.rs | 10 +++--- odorobo/src/http_api/vms.rs | 44 +++++++++++------------ odorobo/src/http_api/volumes.rs | 17 ++++----- odorobo/src/main.rs | 1 + odorobo/src/{http_api => }/types.rs | 26 +++++++++++--- odorobo/src/utils/mod.rs | 54 ++++++++++++++++++++++++++--- 11 files changed, 147 insertions(+), 56 deletions(-) rename odorobo/src/{http_api => }/types.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index 1ed9fb6..2a573c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,30 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "api-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8268700065d79df2f5cff1fa33b24caee27f4ca0c169151d15cb88ea30dc1e73" +dependencies = [ + "api-error-derive", + "axum-core", + "http 1.4.0", + "serde_core", + "serde_json", +] + +[[package]] +name = "api-error-derive" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403ac39ba1ad815cd63231fa3e7b6e4b5db72f45c62ed1effcacd2ad00b8af94" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -2869,6 +2893,7 @@ version = "0.1.0" dependencies = [ "ahash", "aide", + "api-error", "async-trait", "axum", "axum-extra 0.12.6", @@ -2879,6 +2904,7 @@ dependencies = [ "dashmap", "dirs", "futures-util", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -2900,6 +2926,7 @@ dependencies = [ "sysinfo", "thiserror 2.0.18", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index f575cd4..91cee6c 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -46,6 +46,9 @@ ipnet = {version = "2.12", features = ["serde", "schemars08"]} url = { version = "2.5", features = ["serde"] } async-trait = "0.1" dashmap = "6.1" +api-error = { version = "0.1.3", features = ["axum"] } +http = "1" +tower = "0.5" aide = { version = "0.15", features = [ "axum", @@ -59,4 +62,6 @@ aide = { version = "0.15", features = [ # aide needs an older version of schemars schemars = "0.9" + + cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } diff --git a/odorobo/src/actors/agent_actor.rs b/odorobo/src/actors/agent_actor.rs index 932fea8..8eb694a 100644 --- a/odorobo/src/actors/agent_actor.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; +use crate::{config::Config, types::ObjectMetadata, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; use ahash::AHashMap; use bytesize::ByteSize; use cloud_hypervisor_client::models::VmConfig; @@ -23,6 +23,7 @@ pub struct AgentActor { pub config: Config, pub vms: AHashMap, // pub network_actor: ActorRef, + pub metadata: ObjectMetadata, } @@ -57,6 +58,7 @@ impl Actor for AgentActor { memory: ByteSize::b(sys.total_memory()), config, vms: AHashMap::new(), + metadata: ObjectMetadata::default(), }) } diff --git a/odorobo/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs index cc13201..a7d3f73 100644 --- a/odorobo/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -6,7 +6,7 @@ use crate::messages::vm::{ }; use stable_eyre::{Report, Result}; -use crate::http_api::types::CreateVMRequest; +use crate::types::CreateVMRequest; use super::scheduler_actor::SchedulerActor; diff --git a/odorobo/src/http_api/mod.rs b/odorobo/src/http_api/mod.rs index 2130f04..7b7ba40 100644 --- a/odorobo/src/http_api/mod.rs +++ b/odorobo/src/http_api/mod.rs @@ -1,5 +1,4 @@ pub mod nodes; -pub mod types; pub mod vms; pub mod volumes; use aide::{ @@ -10,7 +9,7 @@ use aide::{ use axum::{Extension, Json, Router}; use kameo::actor::ActorRef; -use crate::actors::http_actor::HTTPActor; +use crate::{actors::http_actor::HTTPActor, utils::OdoroboError}; /// Build the full app: finalizes the OpenAPI spec and attaches it as an extension. pub fn build(state: ActorRef) -> Router { @@ -32,12 +31,6 @@ pub fn build(state: ActorRef) -> Router { .layer(Extension(openapi)) } -// todo: error handling -// -// see odorobo-agent's old API for error handling patterns, -// use `thiserror` and `axum_responses` to create consistent error responses across the API -// - cappy - /// Main router for the API fn router(state: ActorRef) -> ApiRouter where @@ -54,8 +47,8 @@ where } /// Serve the OpenAPI spec as JSON -async fn serve_api(Extension(api): Extension) -> impl IntoApiResponse { - Json(api) +async fn serve_api(Extension(api): Extension) -> Result { + Ok(Json(api)) } /// Simple health check endpoint diff --git a/odorobo/src/http_api/nodes.rs b/odorobo/src/http_api/nodes.rs index 65f0634..eff0fa2 100644 --- a/odorobo/src/http_api/nodes.rs +++ b/odorobo/src/http_api/nodes.rs @@ -1,5 +1,5 @@ //! Compute node management API handlers. -use crate::{actors::http_actor::HTTPActor, http_api::types::Node}; +use crate::{actors::http_actor::HTTPActor, types::Node, utils::OdoroboError}; use aide::axum::{ ApiRouter, IntoApiResponse, routing::{get, put}, @@ -16,16 +16,16 @@ pub fn router() -> ApiRouter> { .api_route("/{nodeid}", get(node_info)) } /// Drain a node of all VMs, migrating them away or shutting them down as needed. This is used for maintenance mode. -async fn drain(State(_state): State>) -> impl IntoApiResponse { +async fn drain(State(_state): State>) -> Result { // stub - Json("Draining...".to_string()) + Ok(Json("Draining...".to_string())) } /// Get detailed information about a specific node, including its current VMs and resource usage. async fn node_info( State(_state): State>, Path(_node_id): Path, -) -> impl IntoApiResponse { +) -> Result { // stub, - Json(Node::default()) + Ok(Json(Node::default())) } diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index f21f535..32663f3 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -1,9 +1,9 @@ //! VM management API handlers. use crate::{ actors::http_actor::HTTPActor, - http_api::types::{ - CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VMInfo, VMListResponse, VMStatus, VmId - }, messages::vm::CreateVM, + types::{ + CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VirtualMachine, VMListResponse, VMStatus, VmId + }, messages::vm::CreateVM, utils::OdoroboError, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -28,43 +28,43 @@ pub fn router() -> ApiRouter> { .api_route("/{vmid}/shutdown", put(shutdown_vm)) } -async fn list_vms(State(state): State>) -> impl IntoApiResponse { +async fn list_vms(State(state): State>) -> Result { let reply = state.ask(AgentListVMs).await.unwrap(); - Json(VMListResponse { + Ok(Json(VMListResponse { vms: reply.vms.into_iter().map(VmId).collect(), - }) + })) } /// Get detailed information about a specific VM async fn vm_info( State(_state): State>, Path(VmId(_vmid)): Path, -) -> impl IntoApiResponse { +) -> Result { // stub, - Json(VMInfo::default()) + Ok(Json(VirtualMachine::default())) } async fn create_vm( State(state): State>, Json(request): Json, -) -> impl IntoApiResponse { +) -> Result { let vm_data = request.data.clone(); let message = HTTPActor::create_vm_message(request); let _reply = state.ask(message).await.unwrap(); - Json(VMInfo { + Ok(Json(VirtualMachine { data: vm_data, node: None, status: VMStatus::Provisioning, - }) + })) } async fn debug_create_vm( State(state): State>, Json(request): Json, -) -> impl IntoApiResponse { +) -> Result { let ulid = ulid::Ulid::new(); let message = CreateVM { vmid: ulid, @@ -73,32 +73,32 @@ async fn debug_create_vm( let _reply = state.ask(message).await.unwrap(); - Json(VMInfo { + Ok(Json(VirtualMachine { status: VMStatus::Provisioning, data: VMData { id: ulid, ..Default::default() }, ..Default::default() - }) + })) } async fn delete_vm( State(state): State>, Path(VmId(vmid)): Path, -) -> impl IntoApiResponse { - let _reply = state.ask(DeleteVM { vmid }).await; +) -> Result { + let reply = state.ask(DeleteVM { vmid }).await?; - Json(()) + Ok(Json(())) } async fn shutdown_vm( State(state): State>, Path(VmId(vmid)): Path, -) -> impl IntoApiResponse { +) -> Result { let _reply = state.ask(ShutdownVM { vmid }).await.unwrap(); - Json(()) + Ok(Json(())) } /// Update an existing VM's configuration (e.g. resize, change resources, etc.) @@ -108,8 +108,8 @@ async fn update_vm( State(_state): State>, Path(VmId(_vmid)): Path, Json(_request): Json, -) -> impl IntoApiResponse { +) -> Result { // stub - Json(VMInfo::default()) -} + Ok(Json(VirtualMachine::default())) +} \ No newline at end of file diff --git a/odorobo/src/http_api/volumes.rs b/odorobo/src/http_api/volumes.rs index 35c2cd7..79782a5 100644 --- a/odorobo/src/http_api/volumes.rs +++ b/odorobo/src/http_api/volumes.rs @@ -1,7 +1,7 @@ //! Volume management API handlers. use crate::{ actors::http_actor::HTTPActor, - http_api::types::{CreateVolumeRequest, VolumeId, VolumeInfo}, + types::{CreateVolumeRequest, VolumeId, VolumeInfo}, utils::OdoroboError, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -22,25 +22,26 @@ pub fn router() -> ApiRouter> { } /// Get detailed information about a specific volume -async fn volume_info(Path(VolumeId(_volid)): Path) -> impl IntoApiResponse { +async fn volume_info(Path(VolumeId(_volid)): Path) -> Result { // stub, - Json(VolumeInfo::default()) + Ok(Json(VolumeInfo::default())) } /// Create a new volume with the specified parameters async fn create_volume( State(_state): State>, Json(_request): Json, -) -> impl IntoApiResponse { +) -> Result { // stub - Json(VolumeInfo::default()) + Ok(Json(VolumeInfo::default())) } /// Delete an existing volume by ID async fn delete_volume( State(_state): State>, Path(VolumeId(_volid)): Path, -) -> impl IntoApiResponse { +) -> Result { // stub + Ok(Json(())) } /// Resize an existing volume to a new size @@ -48,7 +49,7 @@ async fn resize_volume( State(_state): State>, Path(VolumeId(_volid)): Path, Json(_request): Json, -) -> impl IntoApiResponse { +) -> Result { // stub - Json(VolumeInfo::default()) + Ok(Json(VolumeInfo::default())) } diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index afacbb5..350843f 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -6,6 +6,7 @@ mod state; mod utils; pub mod messages; pub mod config; +pub mod types; use std::fs; diff --git a/odorobo/src/http_api/types.rs b/odorobo/src/types.rs similarity index 93% rename from odorobo/src/http_api/types.rs rename to odorobo/src/types.rs index a0f037a..78d7c8a 100644 --- a/odorobo/src/http_api/types.rs +++ b/odorobo/src/types.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use aide::OperationIo; use bytesize::ByteSize; use cloud_hypervisor_client::models::VmConfig; @@ -46,7 +48,7 @@ pub struct VmId(#[schemars(with = "String")] pub Ulid); pub struct VolumeId(#[schemars(with = "String")] pub Ulid); /// A URI pointing to the volume's location, e.g an iSCSI URL in `iscsi-inq` format, a local file, or an RBD image. -/// +/// /// examples: /// - `iscsi://[[%]@][:]//` /// - `file:///path/to/volume.img` @@ -134,9 +136,18 @@ pub enum VMStatus { Error(String), // error message } +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +pub struct ObjectMetadata { + /// Labels associated with the object. + pub labels: BTreeMap, + /// Annotations associated with the object. + pub annotations: BTreeMap, +} + /// Detailed information about a running VM +// probably move this somewhere else #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] -pub struct VMInfo { +pub struct VirtualMachine { /// VM configuration pub data: VMData, @@ -148,6 +159,11 @@ pub struct VMInfo { pub node: Option, /// Current status of the VM pub status: VMStatus, + + /// Metadata + pub metadata: Option, + + // placement stuff.... } #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] @@ -167,14 +183,14 @@ pub struct Volume { #[schemars(with = "u64")] #[serde(with = "bytesize_as_u64")] pub size: ByteSize, - + /// A URI pointing to the volume's location, e.g an iSCSI URL in `iscsi-inq` format, a local file, or an RBD image. - /// + /// /// examples: /// - `iscsi://[[%]@][:]//` /// - `file:///path/to/volume.img` /// - `rbd:///` - /// + /// pub uri: StorageUri, } // for now diff --git a/odorobo/src/utils/mod.rs b/odorobo/src/utils/mod.rs index 55bffdb..01c7125 100644 --- a/odorobo/src/utils/mod.rs +++ b/odorobo/src/utils/mod.rs @@ -1,7 +1,8 @@ pub mod actor_names; pub mod actor_cache; -use stable_eyre::{Result}; +use aide::OperationIo; +use stable_eyre::{Result, Report}; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; use thiserror::Error; @@ -9,14 +10,59 @@ use kameo::prelude::*; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::{PeerId, mdns, noise, tcp, yamux}; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, error, info, trace, warn}; +use api_error::ApiError; -#[derive(Error, Debug)] +// todo: wrap with axum-responses, return this type on request failure +#[derive(Error, Debug, ApiError, OperationIo)] +#[aide(output)] pub enum OdoroboError { #[error("{0}")] - Report(#[from] stable_eyre::Report), + #[api_error(status_code = 500, message = "{0}")] + Report(#[from] Report), } +impl From> for OdoroboError { + fn from(value: kameo::error::SendError) -> Self { + let kameo_error = value.to_string(); + error!(?value); + OdoroboError::Report(value.err().unwrap_or_else(|| { + Report::msg(format!("could not unwrap kameo error: {kameo_error}")) + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + http::StatusCode, + body::Body, http::Request, + routing::get, + Router, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + + async fn handler() -> Result<(), OdoroboError> { + Err(OdoroboError::Report(Report::msg("error!"))) + } + + #[tokio::test] + async fn test_error() { + let response = Router::new().route("/", get(handler)) + .oneshot(Request::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = response.into_body(); + let bytes = body.collect().await.unwrap().to_bytes(); + let html = String::from_utf8(bytes.to_vec()).unwrap(); + + assert_eq!(html, "{\"message\":\"error!\"}"); + } +} pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { From e10e4f3afca7e70ee2354f346f747128ca5ebf59 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 15:54:18 -0500 Subject: [PATCH 154/189] forgot to add default for VirtualMachine type in create_vm Co-authored-by: Cappy Ishihara Signed-off-by: Caleb Jones --- odorobo/src/http_api/vms.rs | 1 + odorobo/src/types.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index 32663f3..b8848f3 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -58,6 +58,7 @@ async fn create_vm( data: vm_data, node: None, status: VMStatus::Provisioning, + ..Default::default() })) } diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index 78d7c8a..8e10a25 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -164,6 +164,7 @@ pub struct VirtualMachine { pub metadata: Option, // placement stuff.... + } #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] From e466c7feb8d1bd534d414bd45bfa9016a108a059 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 16:03:34 -0500 Subject: [PATCH 155/189] replace unwrap with ? in http_api Signed-off-by: Caleb Jones --- odorobo/src/http_api/vms.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index b8848f3..672614b 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -29,7 +29,7 @@ pub fn router() -> ApiRouter> { } async fn list_vms(State(state): State>) -> Result { - let reply = state.ask(AgentListVMs).await.unwrap(); + let reply = state.ask(AgentListVMs).await?; Ok(Json(VMListResponse { vms: reply.vms.into_iter().map(VmId).collect(), @@ -52,7 +52,7 @@ async fn create_vm( let vm_data = request.data.clone(); let message = HTTPActor::create_vm_message(request); - let _reply = state.ask(message).await.unwrap(); + let _reply = state.ask(message).await?; Ok(Json(VirtualMachine { data: vm_data, @@ -72,7 +72,7 @@ async fn debug_create_vm( config: request.vm_config, }; - let _reply = state.ask(message).await.unwrap(); + let _reply = state.ask(message).await?; Ok(Json(VirtualMachine { status: VMStatus::Provisioning, @@ -97,7 +97,7 @@ async fn shutdown_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> Result { - let _reply = state.ask(ShutdownVM { vmid }).await.unwrap(); + let _reply = state.ask(ShutdownVM { vmid }).await?; Ok(Json(())) } @@ -113,4 +113,4 @@ async fn update_vm( // stub Ok(Json(VirtualMachine::default())) -} \ No newline at end of file +} From bc4eacdcfe5dfb6592064e53e421ed4f468ce426 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sat, 25 Apr 2026 16:10:36 -0500 Subject: [PATCH 156/189] started this and then realized we might want to merge the crates first Signed-off-by: Caleb Jones --- odorobo/src/types.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index 8e10a25..f91408c 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -105,6 +105,9 @@ pub struct VMData { /// List of volumes to attach to the VM. #[serde(default)] pub volumes: Vec, + /// List of Network IDs. + #[serde(default)] + pub networks: Vec, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] From 46fbede80c092a999a28022a1e32b53198ab202d Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Sun, 26 Apr 2026 19:23:19 -0500 Subject: [PATCH 157/189] start working on affinity rules Signed-off-by: Caleb Jones --- odorobo/src/types.rs | 54 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index f91408c..095af10 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -166,8 +166,58 @@ pub struct VirtualMachine { /// Metadata pub metadata: Option, - // placement stuff.... - + /// List of Affinity rules for scheduling. These are ANDed / summed together depending on the strictness. + pub affinity: Vec +} + + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub struct AffinityRule { + pub strictness: AffinityStrictness, + pub affinity_type: AffinityType, + pub direction: AffinityDirection, + /// ORed together + pub requirements: Vec +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub enum AffinityStrictness { + Required, + Preferred { weight: i64 } +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub enum AffinityType { + VirtualMachine, + Agent +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub enum AffinityDirection { + Normal, + Anti +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub struct AffinityRequirement { + pub key: String, + pub table: MetadataTable, + pub operator: Operator, + pub values: Vec +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub enum MetadataTable { + Label, + Annotation +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +pub enum Operator { + In, + NotIn, + Lt, + Gt, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] From 35dbe69c79aeca097820c7024b18b90824a6c672 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Mon, 27 Apr 2026 15:20:22 -0500 Subject: [PATCH 158/189] remove old ch_api Signed-off-by: Caleb Jones --- odorobo/src/ch_api/ch.rs | 39 ----- odorobo/src/ch_api/console.rs | 259 ---------------------------------- odorobo/src/ch_api/error.rs | 99 ------------- odorobo/src/ch_api/mod.rs | 49 ------- odorobo/src/ch_api/vm.rs | 250 -------------------------------- odorobo/src/main.rs | 1 - odorobo/src/state/mod.rs | 3 +- 7 files changed, 1 insertion(+), 699 deletions(-) delete mode 100644 odorobo/src/ch_api/ch.rs delete mode 100644 odorobo/src/ch_api/console.rs delete mode 100644 odorobo/src/ch_api/error.rs delete mode 100644 odorobo/src/ch_api/mod.rs delete mode 100644 odorobo/src/ch_api/vm.rs diff --git a/odorobo/src/ch_api/ch.rs b/odorobo/src/ch_api/ch.rs deleted file mode 100644 index 6a081cd..0000000 --- a/odorobo/src/ch_api/ch.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::{ - body::Body, - extract::{Path, Request}, - response::Response, -}; -use http_body_util::BodyExt; - -use super::error::ApiError; -use crate::state::VMInstance; - -pub async fn passthrough( - Path((vmid, path)): Path<(String, String)>, - request: Request, -) -> Result { - let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.clone() })?; - - let (mut parts, body) = request.into_parts(); - let body = body - .collect() - .await - .map_err(|e| ApiError::PassthroughFailed { msg: e.to_string() })? - .to_bytes(); - - let path_and_query = match parts.uri.query() { - Some(query) => format!("/{path}?{query}"), - None => format!("/{path}"), - }; - parts.uri = path_and_query - .parse() - .map_err(|e| ApiError::PassthroughFailed { msg: format!("URI parse error: {}", e) })?; - - let response = vm - .call_request(hyper::Request::from_parts(parts, body)) - .await - .map_err(|e| ApiError::PassthroughFailed { msg: e.to_string() })?; - - let (parts, body) = response.into_parts(); - Ok(Response::from_parts(parts, Body::from(body))) -} diff --git a/odorobo/src/ch_api/console.rs b/odorobo/src/ch_api/console.rs deleted file mode 100644 index 5ccf9b5..0000000 --- a/odorobo/src/ch_api/console.rs +++ /dev/null @@ -1,259 +0,0 @@ -use axum::{ - extract::{ - Path, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::Response, -}; -use futures_util::SinkExt; -use serde::{Deserialize, Serialize}; -use stable_eyre::{Result, eyre::eyre}; -use std::io::{self, Read, Write}; -use std::os::fd::AsRawFd; -use tokio::io::unix::AsyncFd; -use tokio::time::{Duration, sleep}; -use tracing::{debug, trace, warn}; - -use super::error::ApiError; -use crate::state::{ConsoleStream, VMInstance}; - -pub async fn console_stream( - vmid: Path, - ws: WebSocketUpgrade, -) -> Result { - let vmid = vmid.0; - let vm = VMInstance::get(&vmid).ok_or_else(|| ApiError::VmNotFound { vmid: vmid.clone() })?; - let console = vm - .open_console() - .await - .map_err(|e| ApiError::ConsoleFailed { msg: e.to_string() })?; - - Ok(ws.on_upgrade(move |socket| proxy_console_socket(vmid, socket, console))) -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ConsoleControlMessage { - Resize { - cols: u16, - rows: u16, - #[serde(default)] - x_pixels: u16, - #[serde(default)] - y_pixels: u16, - }, - ResetSession, -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ConsoleServerMessage<'a> { - Connected { vm_id: &'a str }, - Error { message: &'a str }, -} - -async fn proxy_console_socket(vm_id: String, mut socket: WebSocket, console: ConsoleStream) { - if let Err(err) = send_console_event( - &mut socket, - ConsoleServerMessage::Connected { vm_id: &vm_id }, - ) - .await - { - warn!(vm_id, ?err, "Failed to send connected event"); - return; - } - - let console_fd = match AsyncFd::new(console) { - Ok(fd) => fd, - Err(err) => { - warn!(vm_id, ?err, "Failed to wrap console in AsyncFd"); - return; - } - }; - - match proxy_console(&mut socket, console_fd).await { - Ok(()) => debug!(vm_id, "Console websocket disconnected"), - Err(err) => warn!(vm_id, ?err, "Console websocket proxy failed"), - } - - let _ = socket.close().await; -} - -async fn proxy_console(socket: &mut WebSocket, mut console: AsyncFd) -> Result<()> { - let mut buf = [0_u8; 8192]; - - loop { - // trace!("select waiting on ws.recv() and console.read()"); - tokio::select! { - message = socket.recv() => { - // trace!("ws.recv() returned: {:?}", message.as_ref().map(|m| match m { - // Ok(Message::Binary(d)) => format!("Binary({}b)", d.len()), - // Ok(Message::Text(t)) => format!("Text({}b)", t.len()), - // Ok(Message::Close(_)) => "Close".into(), - // Ok(Message::Ping(_)) => "Ping".into(), - // Ok(Message::Pong(_)) => "Pong".into(), - // Err(e) => format!("Error: {}", e), - // })); - match message { - Some(Ok(Message::Binary(data))) => { - // trace!(bytes = data.len(), "ws -> pty (binary)"); - let _ = console.writable().await?; - let data_copy = data.to_vec(); - let write_result = tokio::task::block_in_place(|| { - console.get_mut().write_all(&data_copy)?; - console.get_mut().flush()?; - Ok::<(), io::Error>(()) - }); - write_result?; - // trace!("write and flush done"); - } - Some(Ok(Message::Text(text))) => { - // trace!(len = text.len(), "ws -> pty (text/control)"); - handle_console_control(socket, &mut console, text.to_string()).await? - } - Some(Ok(Message::Close(_))) | None => { - // trace!("ws close or none"); - break; - } - Some(Ok(Message::Ping(payload))) => { - // trace!("ws ping"); - socket.send(Message::Pong(payload)).await? - } - Some(Ok(Message::Pong(_))) => { - // trace!("ws pong"); - } - Some(Err(err)) => { - trace!(?err, "ws error"); - return Err(err.into()); - } - } - } - read_result = async { - let _ = console.readable().await?; - tokio::task::block_in_place(|| { - console.get_mut().read(&mut buf) - }) - } => { - match read_result { - Ok(n) => { - if n == 0 { - // trace!("pty read returned 0 bytes"); - break; - } - // trace!(bytes = n, "pty -> ws"); - socket.send(Message::Binary(buf[..n].to_vec().into())).await?; - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - // trace!("pty read would block, retrying"); - // Continue select loop to try again - } - Err(e) => return Err(e.into()), - } - } - } - } - - Ok(()) -} - -async fn handle_console_control( - socket: &mut WebSocket, - console: &mut AsyncFd, - raw_message: String, -) -> Result<()> { - let message: ConsoleControlMessage = match serde_json::from_str(&raw_message) { - Ok(message) => message, - Err(_) => { - send_console_event( - socket, - ConsoleServerMessage::Error { - message: "Text frames are reserved for JSON control messages such as {\"type\":\"resize\",\"cols\":120,\"rows\":40} or {\"type\":\"reset_session\"}", - }, - ) - .await?; - return Ok(()); - } - }; - - match message { - ConsoleControlMessage::Resize { - cols, - rows, - x_pixels, - y_pixels, - } => { - if let Err(err) = resize_console(console, cols, rows, x_pixels, y_pixels) { - let message = format!("Failed to resize console: {err}"); - send_console_event(socket, ConsoleServerMessage::Error { message: &message }) - .await?; - } - } - ConsoleControlMessage::ResetSession => { - if let Err(err) = reset_console_session(console).await { - let message = format!("Failed to reset console session: {err}"); - send_console_event(socket, ConsoleServerMessage::Error { message: &message }) - .await?; - } - } - } - - Ok(()) -} - -async fn send_console_event(socket: &mut WebSocket, event: ConsoleServerMessage<'_>) -> Result<()> { - let payload = serde_json::to_string(&event)?; - socket.send(Message::Text(payload.into())).await?; - Ok(()) -} - -fn resize_console( - console: &AsyncFd, - cols: u16, - rows: u16, - x_pixels: u16, - y_pixels: u16, -) -> Result<()> { - if cols == 0 || rows == 0 { - return Err(eyre!("Console dimensions must be greater than zero")); - } - - let size = libc::winsize { - ws_row: rows, - ws_col: cols, - ws_xpixel: x_pixels, - ws_ypixel: y_pixels, - }; - - let result = unsafe { libc::ioctl(console.as_raw_fd(), libc::TIOCSWINSZ, &size) }; - if result == -1 { - return Err(std::io::Error::last_os_error().into()); - } - - Ok(()) -} - -async fn reset_console_session(console: &mut AsyncFd) -> Result<()> { - const STEP_DELAY: Duration = Duration::from_millis(75); - - macro_rules! write_to_console { - ($data:expr) => {{ - let _ = console.writable().await?; - let data = $data.to_vec(); - tokio::task::block_in_place(|| { - console.get_mut().write_all(&data)?; - console.get_mut().flush()?; - Ok::<(), io::Error>(()) - })?; - }}; - } - - write_to_console!(&[0x03]); - sleep(STEP_DELAY).await; - - write_to_console!(b"\r"); - sleep(STEP_DELAY).await; - - write_to_console!(&[0x04]); - - Ok(()) -} diff --git a/odorobo/src/ch_api/error.rs b/odorobo/src/ch_api/error.rs deleted file mode 100644 index ab53845..0000000 --- a/odorobo/src/ch_api/error.rs +++ /dev/null @@ -1,99 +0,0 @@ -use axum_responses::{HttpError, thiserror::Error}; -use stable_eyre::Report; - -use crate::state::ChApiError; - -#[derive(Debug, Error, HttpError)] -pub enum ApiError { - #[error("Invalid VM ID: {msg}")] - #[http(code = 400, message = msg)] - InvalidVmId { msg: String }, - - #[error("VM not found: {vmid}")] - #[http(code = 404, message = vmid)] - VmNotFound { vmid: String }, - - #[error("Failed to get VM info: {msg}")] - #[http(code = 500, message = msg, errors = errors)] - VmInfoFailed { msg: String, errors: Vec }, - - #[error("Failed to list VMs: {msg}")] - #[http(code = 500, message = msg)] - ListFailed { msg: String }, - - #[error("Failed to create VM: {msg}")] - #[http(code = 500, message = msg, errors = errors)] - CreateFailed { msg: String, errors: Vec }, - - #[error("Failed to open VM console: {msg}")] - #[http(code = 500, message = msg)] - ConsoleFailed { msg: String }, - - #[error("Failed to proxy Cloud Hypervisor API request: {msg}")] - #[http(code = 500, message = msg)] - PassthroughFailed { msg: String }, - - #[error("Cloud Hypervisor API error: {msg}")] - #[http(code = 502, message = msg, errors = errors)] - ChApiFailed { msg: String, errors: Vec }, - - #[error("Failed to delete VM configuration: configuration: {msg}")] - #[http(code = 500, message = msg, errors = errors)] - DeleteConfigFailed { msg: String, errors: Vec }, - - #[error("Failed to delete VM configuration: configuration: {msg}")] - #[http(code = 500, message = msg, errors = errors)] - CreateConfigFailed { msg: String, errors: Vec }, - - #[error("Failed to migrate VM: {msg}")] - #[http(code = 500, message = msg, errors = errors)] - MigrationFailed { msg: String, errors: Vec }, -} - -impl ApiError { - fn find_ch_api_error(error: &Report) -> Option<&ChApiError> { - error - .chain() - .find_map(|cause| cause.downcast_ref::()) - } - - fn from_report(error: Report, fallback: impl FnOnce(String, Vec) -> Self) -> Self { - if let Some(ch_error) = Self::find_ch_api_error(&error) { - return match ch_error { - ChApiError::Api { status, errors } => fallback( - format!("Cloud Hypervisor API returned status {}", status.as_u16()), - errors.clone(), - ), - ChApiError::Client(_) => fallback(format!("{error:?}"), vec![]), - }; - } - - fallback(format!("{error:?}"), vec![]) - } - - pub fn vm_info(error: Report) -> Self { - Self::from_report(error, |msg, errors| Self::VmInfoFailed { msg, errors }) - } - - pub fn create(error: Report) -> Self { - Self::from_report(error, |msg, errors| Self::CreateFailed { msg, errors }) - } - - pub fn create_config(error: Report) -> Self { - Self::from_report(error, |msg, errors| Self::CreateConfigFailed { - msg, - errors, - }) - } - - pub fn delete_config(error: Report) -> Self { - Self::from_report(error, |msg, errors| Self::DeleteConfigFailed { - msg, - errors, - }) - } - - pub fn migration(error: Report) -> Self { - Self::from_report(error, |msg, errors| Self::MigrationFailed { msg, errors }) - } -} diff --git a/odorobo/src/ch_api/mod.rs b/odorobo/src/ch_api/mod.rs deleted file mode 100644 index 3b9576c..0000000 --- a/odorobo/src/ch_api/mod.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! REST Management API for odorobo-agent -mod ch; -mod console; -mod error; -mod vm; - -// pub fn router(port: u16) -> axum::Router<()> { -// let info_route = axum::Router::new() -// .route("/info", axum::routing::get(info)) -// .with_state(port); - -// axum::Router::new() -// .layer( -// TraceLayer::new_for_http() -// .on_request(DefaultOnRequest::new()) -// .on_response(DefaultOnResponse::new()) -// ) -// .route("/", axum::routing::get(root)) -// .route("/health", axum::routing::get(health)) -// .merge(info_route) -// .nest("/vms", vm::router()) -// } - -// async fn root() -> &'static str { -// env!("CARGO_PKG_VERSION") -// } - -// async fn health() -> &'static str { -// "" -// } - -// #[derive(Serialize)] -// struct AgentInfo { -// version: &'static str, -// listening_addresses: Vec, -// } - -// async fn info(State(port): State) -> Json { -// let listening_addresses = if_addrs::get_if_addrs() -// .unwrap_or_default() -// .into_iter() -// .filter(|i| !i.is_loopback()) -// .map(|i| format!("{}:{}", i.ip(), port)) -// .collect(); -// Json(AgentInfo { -// version: env!("CARGO_PKG_VERSION"), -// listening_addresses, -// }) -// } diff --git a/odorobo/src/ch_api/vm.rs b/odorobo/src/ch_api/vm.rs deleted file mode 100644 index 85b9a6d..0000000 --- a/odorobo/src/ch_api/vm.rs +++ /dev/null @@ -1,250 +0,0 @@ - - -// pub fn router() -> axum::Router<()> { -// axum::Router::new() -// .route("/", axum::routing::get(list_vms)) -// // .route("/{vmid}", axum::routing::put(spawn_vm)) -// // .route("/{vmid}/config", axum::routing::put(create_vm_config)) -// // .route("/{vmid}/config", axum::routing::delete(delete_vm_config)) -// // .route("/{vmid}/shutdown", axum::routing::put(shutdown_vm)) -// // .route("/{vmid}/acpi_shutdown", axum::routing::put(shutdown_acpi)) -// // .route("/{vmid}/boot", axum::routing::put(boot_vm)) -// // .route("/{vmid}/pause", axum::routing::put(pause_vm)) -// // .route("/{vmid}/resume", axum::routing::put(resume_vm)) -// // .route("/{vmid}/migrate/send", axum::routing::put(migrate_send_vm)) -// // .route( -// // "/{vmid}/migrate/receive", -// // axum::routing::put(migrate_receive_vm), -// // ) -// // .route("/{vmid}", axum::routing::get(vm_info)) -// // .route("/{vmid}/ping", axum::routing::get(ping_vm)) -// // .route("/{vmid}", axum::routing::delete(destroy_vm)) -// // .route( -// // "/{vmid}/console", -// // axum::routing::get(super::console::console_stream), -// // ) -// // .route( -// // "/{vmid}/ch/{*path}", -// // axum::routing::any(super::ch::passthrough), -// // ) -// } - -// /// Lists all VMs by their IDs -// async fn list_vms() -> Result>, ApiError> { -// let vms = VMInstance::list().map_err(|e| ApiError::ListFailed { msg: e.to_string() })?; -// Ok(Json(vms.into_iter().map(|i| i.id).collect())) -// } - -// /// Helper function to get a VM instance by ID, returning an error if not found -// fn get_vm(vmid: &str) -> Result { -// use crate::state::VMInstance; -// VMInstance::validate_vmid(vmid).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; -// VMInstance::get(vmid).ok_or_else(|| ApiError::VmNotFound { -// vmid: vmid.to_string(), -// }) -// } - -// /// Gets detailed information about a specific VM -// async fn vm_info( -// vmid: Path, -// ) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; - -// let info = vm.info().await.map_err(ApiError::vm_info)?; -// Ok(Json(info)) -// } - -// /// Pings the VMM to check if it's running -// async fn ping_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// let res = vm.ping().await.map_err(ApiError::vm_info)?; -// Ok(Json(res)) -// } - -// #[derive(Debug, Deserialize)] -// pub struct CreateVmQuery { -// #[serde(default)] -// /// Whether to boot the VM immediately -// /// after creation -// /// -// /// Defaults to `false`. -// pub boot: bool, -// } -// #[derive(Debug, Deserialize, Serialize)] -// pub struct VmSpawnResponse { -// pub info: Option, -// pub booted: bool, -// pub created_config: bool, -// } - -// #[derive(Debug, Deserialize, Serialize)] -// pub struct VmMigrateSendResponse { -// pub info: Option, -// } -// #[derive(Debug, Deserialize, Serialize)] -// pub struct VmMigrateReceiveResponse { -// pub listening_address: String, -// } - -// #[derive(Debug, Deserialize)] -// pub struct VmMigrateSendRequest { -// pub destination: String, -// #[serde(default)] -// pub local: bool, -// } - -// /// Sends a live migration to the given destination URL. -// /// Note: the source VMM exits after migration completes, so no VM info is returned. -// async fn migrate_send_vm( -// vmid: Path, -// Json(body): Json, -// ) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.send_migration(&body.destination, body.local) -// .await -// .map_err(ApiError::migration)?; -// Ok(Json(VmMigrateSendResponse { info: None })) -// } - -// /// Prepares a VM to receive a live migration, returning the address the sender should connect to -// async fn migrate_receive_vm( -// vmid: Path, -// ) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// let listening_address = vm.receive_migration().await.map_err(ApiError::migration)?; -// Ok(Json(VmMigrateReceiveResponse { listening_address })) -// } - -// /// Spawns a new VM instance with the given ID, optionally creating it with the provided configuration and booting it immediately -// async fn spawn_vm( -// vmid: Path, -// Query(query): Query, -// vm_config: Option>, -// ) -> Result, ApiError> { -// VMInstance::validate_vmid(&vmid.0).map_err(|e| ApiError::InvalidVmId { msg: e.to_string() })?; -// let vm_config = vm_config.map(|Json(vm_config)| vm_config); - -// // check if VM already exists, if so error out for already existing instance -// if VMInstance::get(&vmid.0).is_some() { -// error!(vmid = ?vmid, "VM with this ID already exists"); -// return Err(ApiError::CreateFailed { -// msg: "VM with this ID already exists".to_string(), -// errors: vec![], -// }); -// } - -// trace!(?vmid, ?query, "Creating VM with config"); -// // trace!(?vm_config, "VM config details"); -// let runtime_dir = VMInstance::runtime_dir_for(&vmid.0); -// std::fs::create_dir_all(&runtime_dir).map_err(|e| { -// error!(error = %e, "Failed to create runtime dir"); -// ApiError::CreateFailed { -// msg: e.to_string(), -// errors: vec![], -// } -// })?; -// // trace!(?) - -// let vm = VMInstance::spawn(&vmid.0).await.map_err(|e| { -// error!(error = ?e, "Failed to spawn VM process"); -// ApiError::create(e) -// })?; - -// let mut created = false; -// if vm_config.is_some() { -// trace!(?vmid, "Creating VM with provided config"); -// vm.create(vm_config.clone().unwrap(), query.boot) -// .await -// .map_err(|e| { -// error!(error = ?e, "Failed to create VM"); -// ApiError::create_config(e) -// })?; - -// created = true; -// } else { -// trace!(?vmid, "No VM config provided, skipping creation step"); -// } - -// let vm_info = if vm_config.is_some() { -// Some(vm.info().await.map_err(|e| { -// error!(error = ?e, "Failed to get VM info after creation"); -// ApiError::vm_info(e) -// })?) -// } else { -// None -// }; - -// Ok(Json(VmSpawnResponse { -// info: vm_info, -// booted: query.boot, -// created_config: created, -// })) -// } - -// async fn create_vm_config( -// vmid: Path, -// Query(query): Query, -// Json(vm_config): Json, -// ) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.create(vm_config, query.boot) -// .await -// .map_err(ApiError::create_config)?; -// Ok(Json(())) -// } - -// async fn delete_vm_config(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.delete_config().await.map_err(ApiError::delete_config)?; -// Ok(Json(())) -// } - -// /// Forces a VM to shut down immediately, without giving it a chance to gracefully clean up resources. -// /// This is equivalent to pulling the power on a physical machine and may lead to data loss or corruption if the VM is running. -// /// -// /// The VM process itself will still be running until the VMM detects that the VM has stopped, -// /// not fully cleaning up resources. -// async fn shutdown_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.shutdown().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } - -// /// Sends an ACPI shutdown signal to the VM, allowing it to gracefully shut down and clean up resources. -// /// -// /// With the systemd provisioner, this will also fully clean up resources and destroy the VM instance entirely, -// /// allowing them to be re-provisioned again on any other node (if running in a cluster) -// async fn shutdown_acpi(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.acpi_power_button().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } - -// /// Boots a VM that has been created but not yet started. If the VM is already running, this will return an error. -// async fn boot_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.boot().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } - -// /// Suspends a running VM, pausing all activity until -// /// it is resumed again. -// async fn pause_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.pause().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } - -// /// Resumes a paused VM, allowing it to continue running from where it left off. -// async fn resume_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.resume().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } - -// /// Destroys a VM, stopping it if it's running and cleaning up resources -// async fn destroy_vm(vmid: Path) -> Result, ApiError> { -// let vm = get_vm(&vmid.0)?; -// vm.destroy().await.map_err(ApiError::vm_info)?; -// Ok(Json(())) -// } diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index 350843f..e7f3215 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -1,5 +1,4 @@ pub mod actors; -mod ch_api; pub mod http_api; pub mod networking; mod state; diff --git a/odorobo/src/state/mod.rs b/odorobo/src/state/mod.rs index 00fa350..91b99c0 100644 --- a/odorobo/src/state/mod.rs +++ b/odorobo/src/state/mod.rs @@ -9,5 +9,4 @@ pub mod instance; pub mod provisioning; pub mod transform; -pub use instance::{ChApiError, ConsoleStream, VMInstance}; -// pub use transform::{ConfigTransform, ConsoleTransform, TransformChain, apply_builtin_transforms}; +pub use instance::{VMInstance}; From 1c60eac03abd0924d02030dea56c232e2813f91f Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Mon, 27 Apr 2026 17:01:54 -0500 Subject: [PATCH 159/189] rename state module to ch_driver to make more sense. start work on isolating ch references. will move this to another crate later. Signed-off-by: Caleb Jones --- odorobo/src/actors/agent_actor.rs | 2 +- odorobo/src/actors/scheduler_actor.rs | 2 +- .../actor/mod.rs => ch_driver/actor.rs} | 95 ++++++++++++++++--- odorobo/src/{state => ch_driver}/api.rs | 0 odorobo/src/{state => ch_driver}/devices.rs | 2 +- odorobo/src/{state => ch_driver}/instance.rs | 2 +- odorobo/src/{state => ch_driver}/mod.rs | 1 + .../provisioning/hooks/mod.rs | 0 .../provisioning/hooks/networking.rs | 0 .../{state => ch_driver}/provisioning/mod.rs | 1 - .../{state => ch_driver}/transform/console.rs | 2 +- .../src/{state => ch_driver}/transform/mod.rs | 0 .../transform/networking.rs | 0 .../transform/path_verify.rs | 0 .../transform/storage/file.rs | 0 .../transform/storage/iscsi.rs | 0 .../transform/storage/mod.rs | 2 +- .../transform/storage/rbd.rs | 0 odorobo/src/main.rs | 2 +- 19 files changed, 92 insertions(+), 19 deletions(-) rename odorobo/src/{state/provisioning/actor/mod.rs => ch_driver/actor.rs} (75%) rename odorobo/src/{state => ch_driver}/api.rs (100%) rename odorobo/src/{state => ch_driver}/devices.rs (96%) rename odorobo/src/{state => ch_driver}/instance.rs (99%) rename odorobo/src/{state => ch_driver}/mod.rs (95%) rename odorobo/src/{state => ch_driver}/provisioning/hooks/mod.rs (100%) rename odorobo/src/{state => ch_driver}/provisioning/hooks/networking.rs (100%) rename odorobo/src/{state => ch_driver}/provisioning/mod.rs (93%) rename odorobo/src/{state => ch_driver}/transform/console.rs (98%) rename odorobo/src/{state => ch_driver}/transform/mod.rs (100%) rename odorobo/src/{state => ch_driver}/transform/networking.rs (100%) rename odorobo/src/{state => ch_driver}/transform/path_verify.rs (100%) rename odorobo/src/{state => ch_driver}/transform/storage/file.rs (100%) rename odorobo/src/{state => ch_driver}/transform/storage/iscsi.rs (100%) rename odorobo/src/{state => ch_driver}/transform/storage/mod.rs (99%) rename odorobo/src/{state => ch_driver}/transform/storage/rbd.rs (100%) diff --git a/odorobo/src/actors/agent_actor.rs b/odorobo/src/actors/agent_actor.rs index 8eb694a..7f0fd6a 100644 --- a/odorobo/src/actors/agent_actor.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, types::ObjectMetadata, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, state::provisioning::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; +use crate::{config::Config, types::ObjectMetadata, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, ch_driver::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; use ahash::AHashMap; use bytesize::ByteSize; use cloud_hypervisor_client::models::VmConfig; diff --git a/odorobo/src/actors/scheduler_actor.rs b/odorobo/src/actors/scheduler_actor.rs index 3e97ab8..11d6a62 100644 --- a/odorobo/src/actors/scheduler_actor.rs +++ b/odorobo/src/actors/scheduler_actor.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use kameo::prelude::*; use libp2p::futures::TryStreamExt; use crate::actors::agent_actor::AgentActor; -use crate::state::provisioning::actor::VMActor; +use crate::ch_driver::actor::VMActor; use crate::utils::actor_cache::ActorCache; use crate::utils::actor_cache::ActorCacheUpdater; use crate::utils::actor_names::VM; diff --git a/odorobo/src/state/provisioning/actor/mod.rs b/odorobo/src/ch_driver/actor.rs similarity index 75% rename from odorobo/src/state/provisioning/actor/mod.rs rename to odorobo/src/ch_driver/actor.rs index ad32cbf..ca968f7 100644 --- a/odorobo/src/state/provisioning/actor/mod.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -1,5 +1,5 @@ -use crate::state::VMInstance; -use cloud_hypervisor_client::models::VmConfig; +use crate::{ch_driver::VMInstance, types::VirtualMachine}; +use cloud_hypervisor_client::models::{ConsoleConfig, ConsoleMode, CpusConfig, DebugConsoleConfig, DiskConfig, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, RngConfig, VmConfig}; use kameo::prelude::*; use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, @@ -9,14 +9,7 @@ use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use tokio::task::JoinHandle; use tracing::{debug, error, info, trace, warn}; -/* -use std::process::Command; - -let output = Command::new("echo") -.arg("Hello world") -.output() -.expect("Failed to execute command"); - */ + /// A migration state that holds the listening address and VM config for a migration, /// used to pass live migration data between actors. pub struct MigrationState { @@ -39,11 +32,13 @@ pub struct VMActor { impl Actor for VMActor { // tuple of VM ID and optional config - type Args = (ulid::Ulid, Option); + type Args = (ulid::Ulid, Option); type Error = Report; #[tracing::instrument(skip_all)] async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { + + let mut vminstance = VMInstance::spawn(&vmid.to_string(), vm_config, None).await?; // Take the child process out so we can watch for unexpected death. @@ -107,6 +102,84 @@ impl Actor for VMActor { } } +impl From for VmConfig { + fn from(vm: VirtualMachine) -> Self { + VmConfig { + cpus: Some(CpusConfig { + boot_vcpus: todo!(), + max_vcpus: todo!(), + topology: todo!(), + kvm_hyperv: todo!(), + max_phys_bits: todo!(), + nested: todo!(), + affinity: todo!(), + features: todo!(), + core_scheduling: todo!(), + }), + memory: Some(MemoryConfig { + size: todo!(), + hotplug_size: todo!(), + hotplugged_size: todo!(), + mergeable: todo!(), + hotplug_method: todo!(), + shared: todo!(), + hugepages: todo!(), + hugepage_size: todo!(), + prefault: todo!(), + thp: todo!(), + zones: todo!(), + }), + payload: PayloadConfig { + firmware: todo!(), + kernel: None, + cmdline: todo!(), + initramfs: todo!(), + igvm: todo!(), + host_data: todo!(), + }, + disks: Some(vec![ + DiskConfig { + ..Default::default() + } + ]), + net: Some(vec![ + NetConfig { + ..Default::default() + } + ]), + rng: Some( + RngConfig { + src: "/dev/urandom".to_string(), + iommu: Some(false) + } + ), + serial: Some( + ConsoleConfig { + mode: ConsoleMode::Null, + iommu: Some(false), + ..Default::default() + } + ), + debug_console: Some( + DebugConsoleConfig { + mode: ConsoleMode::Off, + iobase: Some(233), + ..Default::default() + } + ), + iommu: Some(false), + watchdog: Some(false), + pvpanic: Some(false), + platform: Some(PlatformConfig { + serial_number: Some("ds=nocloud".to_string()), + ..Default::default() + }), + landlock_enable: Some(false), + ..Default::default() + } + } +} + // allow conversion from VMActor to VMInstance to call API impl From for VMInstance { fn from(actor: VMActor) -> Self { diff --git a/odorobo/src/state/api.rs b/odorobo/src/ch_driver/api.rs similarity index 100% rename from odorobo/src/state/api.rs rename to odorobo/src/ch_driver/api.rs diff --git a/odorobo/src/state/devices.rs b/odorobo/src/ch_driver/devices.rs similarity index 96% rename from odorobo/src/state/devices.rs rename to odorobo/src/ch_driver/devices.rs index 5558eb8..2d02a0c 100644 --- a/odorobo/src/state/devices.rs +++ b/odorobo/src/ch_driver/devices.rs @@ -1,4 +1,4 @@ -use crate::state::VMInstance; +use crate::ch_driver::VMInstance; use cloud_hypervisor_client::{ apis::DefaultApi, models::{DeviceConfig, NetConfig, PciDeviceInfo, VmRemoveDevice}, diff --git a/odorobo/src/state/instance.rs b/odorobo/src/ch_driver/instance.rs similarity index 99% rename from odorobo/src/state/instance.rs rename to odorobo/src/ch_driver/instance.rs index ecbf347..8433f7e 100644 --- a/odorobo/src/state/instance.rs +++ b/odorobo/src/ch_driver/instance.rs @@ -17,7 +17,7 @@ use thiserror::Error; use tokio::task::JoinHandle; use tracing::{debug, error, info, trace, warn}; -use crate::state::{ +use crate::ch_driver::{ provisioning::hooks::HookManager, transform::{ConfigTransform, TransformChain}, }; diff --git a/odorobo/src/state/mod.rs b/odorobo/src/ch_driver/mod.rs similarity index 95% rename from odorobo/src/state/mod.rs rename to odorobo/src/ch_driver/mod.rs index 91b99c0..8701f5b 100644 --- a/odorobo/src/state/mod.rs +++ b/odorobo/src/ch_driver/mod.rs @@ -8,5 +8,6 @@ pub mod devices; pub mod instance; pub mod provisioning; pub mod transform; +pub mod actor; pub use instance::{VMInstance}; diff --git a/odorobo/src/state/provisioning/hooks/mod.rs b/odorobo/src/ch_driver/provisioning/hooks/mod.rs similarity index 100% rename from odorobo/src/state/provisioning/hooks/mod.rs rename to odorobo/src/ch_driver/provisioning/hooks/mod.rs diff --git a/odorobo/src/state/provisioning/hooks/networking.rs b/odorobo/src/ch_driver/provisioning/hooks/networking.rs similarity index 100% rename from odorobo/src/state/provisioning/hooks/networking.rs rename to odorobo/src/ch_driver/provisioning/hooks/networking.rs diff --git a/odorobo/src/state/provisioning/mod.rs b/odorobo/src/ch_driver/provisioning/mod.rs similarity index 93% rename from odorobo/src/state/provisioning/mod.rs rename to odorobo/src/ch_driver/provisioning/mod.rs index be490cc..24bdf32 100644 --- a/odorobo/src/state/provisioning/mod.rs +++ b/odorobo/src/ch_driver/provisioning/mod.rs @@ -2,5 +2,4 @@ //! //! Provides helper functions that calls the necessary hooks and various methods to start a //! Cloud Hypervisor process for a given instance -pub mod actor; pub mod hooks; diff --git a/odorobo/src/state/transform/console.rs b/odorobo/src/ch_driver/transform/console.rs similarity index 98% rename from odorobo/src/state/transform/console.rs rename to odorobo/src/ch_driver/transform/console.rs index b76711a..e675f06 100644 --- a/odorobo/src/state/transform/console.rs +++ b/odorobo/src/ch_driver/transform/console.rs @@ -2,7 +2,7 @@ use cloud_hypervisor_client::models::{ConsoleConfig, VmConfig}; use stable_eyre::Result; use tracing::trace; -use crate::state::VMInstance; +use crate::ch_driver::VMInstance; use super::ConfigTransform; diff --git a/odorobo/src/state/transform/mod.rs b/odorobo/src/ch_driver/transform/mod.rs similarity index 100% rename from odorobo/src/state/transform/mod.rs rename to odorobo/src/ch_driver/transform/mod.rs diff --git a/odorobo/src/state/transform/networking.rs b/odorobo/src/ch_driver/transform/networking.rs similarity index 100% rename from odorobo/src/state/transform/networking.rs rename to odorobo/src/ch_driver/transform/networking.rs diff --git a/odorobo/src/state/transform/path_verify.rs b/odorobo/src/ch_driver/transform/path_verify.rs similarity index 100% rename from odorobo/src/state/transform/path_verify.rs rename to odorobo/src/ch_driver/transform/path_verify.rs diff --git a/odorobo/src/state/transform/storage/file.rs b/odorobo/src/ch_driver/transform/storage/file.rs similarity index 100% rename from odorobo/src/state/transform/storage/file.rs rename to odorobo/src/ch_driver/transform/storage/file.rs diff --git a/odorobo/src/state/transform/storage/iscsi.rs b/odorobo/src/ch_driver/transform/storage/iscsi.rs similarity index 100% rename from odorobo/src/state/transform/storage/iscsi.rs rename to odorobo/src/ch_driver/transform/storage/iscsi.rs diff --git a/odorobo/src/state/transform/storage/mod.rs b/odorobo/src/ch_driver/transform/storage/mod.rs similarity index 99% rename from odorobo/src/state/transform/storage/mod.rs rename to odorobo/src/ch_driver/transform/storage/mod.rs index 1021465..1e04735 100644 --- a/odorobo/src/state/transform/storage/mod.rs +++ b/odorobo/src/ch_driver/transform/storage/mod.rs @@ -1,4 +1,4 @@ -use crate::state::transform::ConfigTransform; +use crate::ch_driver::transform::ConfigTransform; use async_trait::async_trait; use cloud_hypervisor_client::models::VmConfig; use stable_eyre::Result; diff --git a/odorobo/src/state/transform/storage/rbd.rs b/odorobo/src/ch_driver/transform/storage/rbd.rs similarity index 100% rename from odorobo/src/state/transform/storage/rbd.rs rename to odorobo/src/ch_driver/transform/storage/rbd.rs diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index e7f3215..cd8dd39 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -1,7 +1,7 @@ pub mod actors; pub mod http_api; pub mod networking; -mod state; +mod ch_driver; mod utils; pub mod messages; pub mod config; From 05cb7d9df44e120e9a07e3c7457221a1d051788c Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Tue, 28 Apr 2026 14:44:48 -0500 Subject: [PATCH 160/189] [wip] switch ch_driver to use VirtualMachine instead of VmConfig Signed-off-by: Caleb Jones --- Cargo.lock | 14 +++++++ Cargo.toml | 2 +- README.md | 2 +- odorobo/src/actors/agent_actor.rs | 11 +++-- odorobo/src/actors/http_actor.rs | 28 ------------- odorobo/src/ch_driver/actor.rs | 67 +++++++++++++++++-------------- odorobo/src/http_api/vms.rs | 16 +++----- odorobo/src/messages/vm.rs | 6 ++- odorobo/src/types.rs | 22 +++++----- odoroboctl/src/cli.rs | 17 ++------ 10 files changed, 82 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a573c2..2b522be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,6 +668,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -2934,6 +2935,19 @@ dependencies = [ "url", ] +[[package]] +name = "odoroboctl" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "stable-eyre", + "tokio", +] + [[package]] name = "oid-registry" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index ce70611..5d2725a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["odorobo"] +members = ["odorobo", "odoroboctl"] [workspace.package] rust-version = "1.95.0" diff --git a/README.md b/README.md index c2f7e24..8b05fc8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ sudo dnf in -y clang-devel nftables cloud-hypervisor cargo build --release # Run the Agent & Manager (requires write permissions to /run/odorobo) -sudo ./target/release/odorobo --manager-enabled=true # or set ODOROBO_MANAGER_ENABLED=true +sudo ./target/release/odorobo --manager-enabled # or set ODOROBO_MANAGER_ENABLED=true # Run on other boxes sudo ./target/release/odorobo diff --git a/odorobo/src/actors/agent_actor.rs b/odorobo/src/actors/agent_actor.rs index 7f0fd6a..3745c2d 100644 --- a/odorobo/src/actors/agent_actor.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -1,7 +1,6 @@ -use crate::{config::Config, types::ObjectMetadata, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, ch_driver::actor::VMActor, utils::actor_names::{NETWORK, VM, vm_actor_id}}; +use crate::{ch_driver::actor::VMActor, config::Config, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, types::{ObjectMetadata, VirtualMachine}, utils::actor_names::{NETWORK, VM, vm_actor_id}}; use ahash::AHashMap; use bytesize::ByteSize; -use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; use stable_eyre::{Report, Result}; use std::ops::ControlFlow; @@ -13,7 +12,7 @@ use kameo::error::PanicError; pub struct VMCacheData { actor_ref: ActorRef, - config: VmConfig + config: VirtualMachine } #[derive(RemoteActor)] @@ -137,7 +136,7 @@ impl Message for AgentActor { vmid, VMCacheData { actor_ref: actor_ref.clone(), - config: msg.config.clone() + config: Default::default() } ); @@ -285,12 +284,12 @@ impl Message for AgentActor { ) -> Self::Reply { let vcpus_used_by_vms = self.vms.values() - .map(|vm| vm.config.cpus.as_ref().map(|cpu_config| cpu_config.boot_vcpus).unwrap_or(0)) + .map(|vm| vm.config.data.vcpus) .reduce(|acc, cpus| acc + cpus) .unwrap_or(0) as u32; let ram_used_by_vms = self.vms.values() - .map(|vm| vm.config.memory.as_ref().map(|memory_config| memory_config.size).unwrap_or(0)) + .map(|vm| vm.config.data.memory.as_u64()) .reduce(|acc, memory| acc + memory) .unwrap_or(0) as u64; diff --git a/odorobo/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs index a7d3f73..5728d79 100644 --- a/odorobo/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -17,34 +17,6 @@ pub struct HTTPActor { pub scheduler: ActorRef, } -impl HTTPActor { - pub fn create_vm_message(request: CreateVMRequest) -> CreateVM { - CreateVM { - vmid: request.data.id, - config: VmConfig { - cpus: Some(CpusConfig { - boot_vcpus: request.data.vcpus as i32, - max_vcpus: request - .data - .max_vcpus - .map(|v| v as i32) - .unwrap_or(request.data.vcpus as i32), - ..Default::default() - }), - memory: Some(MemoryConfig { - size: request.data.memory.as_u64() as i64, - ..Default::default() - }), - payload: PayloadConfig { - kernel: Some(request.data.image), - ..Default::default() - }, - ..Default::default() - }, - } - } -} - impl Actor for HTTPActor { type Args = ActorRef; type Error = Report; diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index ca968f7..2c716f1 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -1,5 +1,5 @@ use crate::{ch_driver::VMInstance, types::VirtualMachine}; -use cloud_hypervisor_client::models::{ConsoleConfig, ConsoleMode, CpusConfig, DebugConsoleConfig, DiskConfig, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, RngConfig, VmConfig}; +use cloud_hypervisor_client::models::{ConsoleConfig, ConsoleMode, CpuFeatures, CpusConfig, DebugConsoleConfig, DiskConfig, ImageType, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, RngConfig, VmConfig}; use kameo::prelude::*; use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, @@ -37,9 +37,7 @@ impl Actor for VMActor { #[tracing::instrument(skip_all)] async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { - - - let mut vminstance = VMInstance::spawn(&vmid.to_string(), vm_config, None).await?; + let mut vminstance = VMInstance::spawn(&vmid.to_string(), vm_config.map(VmConfig::from), None).await?; // Take the child process out so we can watch for unexpected death. // destroy() handles a missing child_process gracefully. @@ -106,44 +104,53 @@ impl From for VmConfig { fn from(vm: VirtualMachine) -> Self { VmConfig { cpus: Some(CpusConfig { - boot_vcpus: todo!(), - max_vcpus: todo!(), - topology: todo!(), - kvm_hyperv: todo!(), - max_phys_bits: todo!(), - nested: todo!(), - affinity: todo!(), - features: todo!(), - core_scheduling: todo!(), + boot_vcpus: 4, + max_vcpus: 4, + kvm_hyperv: Some(false), + max_phys_bits: Some(46), + nested: Some(false), + features: Some(CpuFeatures { + amx: Some(false) + }), + ..Default::default() }), memory: Some(MemoryConfig { - size: todo!(), - hotplug_size: todo!(), - hotplugged_size: todo!(), - mergeable: todo!(), - hotplug_method: todo!(), - shared: todo!(), - hugepages: todo!(), - hugepage_size: todo!(), - prefault: todo!(), - thp: todo!(), - zones: todo!(), + size: 4294967296, + mergeable: Some(false), + hotplug_method: Some("Acpi".to_string()), + shared: Some(true), + hugepages: Some(false), + prefault: Some(false), + thp: Some(true), + ..Default::default() }), payload: PayloadConfig { - firmware: todo!(), - kernel: None, - cmdline: todo!(), - initramfs: todo!(), - igvm: todo!(), - host_data: todo!(), + firmware: Some("/var/lib/odorobo/CLOUDHV.fd".to_string()), + cmdline: Some("odorobo.test=1".to_string()), + ..Default::default() }, disks: Some(vec![ DiskConfig { + // todo: the json i was given by cappy had disable_io_uring and disable_aio in this config, but I can't find these. I assume they were just a mistake. + path: Some("/var/lib/odorobo/f43.raw".to_string()), + readonly: Some(false), + direct: Some(false), + iommu: Some(false), + num_queues: Some(1), + queue_size: Some(128), + vhost_user: Some(false), + id: Some("_disk0".to_string()), + pci_segment: Some(0), + backing_files: Some(false), + sparse: Some(true), + image_type: Some(ImageType::Raw), ..Default::default() } ]), net: Some(vec![ NetConfig { + id: Some("net://devnet".to_string()), + mac: Some("46:59:52:67:67:67".to_string()), ..Default::default() } ]), diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index 672614b..22285db 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -49,27 +49,23 @@ async fn create_vm( State(state): State>, Json(request): Json, ) -> Result { - let vm_data = request.data.clone(); - let message = HTTPActor::create_vm_message(request); + let message = CreateVM { + vmid: request.data.data.id, + config: request.data.clone(), + }; let _reply = state.ask(message).await?; - Ok(Json(VirtualMachine { - data: vm_data, - node: None, - status: VMStatus::Provisioning, - ..Default::default() - })) + Ok(Json(request)) } async fn debug_create_vm( State(state): State>, - Json(request): Json, ) -> Result { let ulid = ulid::Ulid::new(); let message = CreateVM { vmid: ulid, - config: request.vm_config, + config: Default::default(), }; let _reply = state.ask(message).await?; diff --git a/odorobo/src/messages/vm.rs b/odorobo/src/messages/vm.rs index a1dd566..46f08f5 100644 --- a/odorobo/src/messages/vm.rs +++ b/odorobo/src/messages/vm.rs @@ -4,6 +4,8 @@ use kameo::prelude::*; use serde::{Deserialize, Serialize}; use ulid::Ulid; +use crate::types::VirtualMachine; + // TODO: when scheduler does createVM it also stores which server we put the Ulid on so it can do a in memory cache and doesn't need to hit the Server // for failover, the new node when it fails over will need to rebuild this cache via hitting a GetAllVMs message on every server // additionally, when the VmConfig is created, this determines the MAC address of the server. meaning as soon as we have this info, we need to hit the router via the scheduler, because the router might be slow. @@ -20,12 +22,12 @@ pub struct CreateVM { /// node-specific, paths, i.e attach LUNs, networking? /// /// this data would go to state::instance::spawn() - pub config: VmConfig, + pub config: VirtualMachine, } #[derive(Serialize, Deserialize, Reply, Debug)] pub struct CreateVMReply { - pub config: Option, + pub config: Option, } /// Message to delete a VM's config from the agent, shutting it down diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index 095af10..d799f3b 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -66,7 +66,7 @@ impl Default for StorageUri { #[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct CreateVMRequest { /// Data of the VM to create - pub data: VMData, + pub data: VirtualMachine, /// Whether to boot the VM immediately after creation pub boot: bool, } @@ -127,7 +127,7 @@ pub struct UpdateVMRequest { pub volumes: Vec, } -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub enum VMStatus { /// VM is currently running and operational. Running, @@ -139,7 +139,7 @@ pub enum VMStatus { Error(String), // error message } -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct ObjectMetadata { /// Labels associated with the object. pub labels: BTreeMap, @@ -149,7 +149,7 @@ pub struct ObjectMetadata { /// Detailed information about a running VM // probably move this somewhere else -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct VirtualMachine { /// VM configuration pub data: VMData, @@ -171,7 +171,7 @@ pub struct VirtualMachine { } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub struct AffinityRule { pub strictness: AffinityStrictness, pub affinity_type: AffinityType, @@ -180,25 +180,25 @@ pub struct AffinityRule { pub requirements: Vec } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityStrictness { Required, Preferred { weight: i64 } } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityType { VirtualMachine, Agent } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityDirection { Normal, Anti } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub struct AffinityRequirement { pub key: String, pub table: MetadataTable, @@ -206,13 +206,13 @@ pub struct AffinityRequirement { pub values: Vec } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum MetadataTable { Label, Annotation } -#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum Operator { In, NotIn, diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 98bdbf0..28cada4 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -27,15 +27,7 @@ pub struct Cli { pub enum Command { /// Create a VM via the scheduler debug endpoint, /// optionally also booting it immediately after creation (if `--boot` is specified). - Create { - /// Path to the VM config file - /// (in Cloud Hypervisor JSON format) - config: PathBuf, - - /// Boot the VM after creation - #[arg(long)] - boot: bool, - }, + Create, /// List VMs currently known by the manager/agent. List, @@ -109,12 +101,9 @@ pub async fn run_command(cli: Cli) -> Result<()> { let base_url = cli.manager_addr; match cli.command { - Command::Create { config, boot } => { + Command::Create => { let url = format!("{}/vms", base_url); - let vm_config = - serde_json::from_str::(&std::fs::read_to_string(&config)?)?; - let body = DebugCreateVMRequest { vm_config, boot }; - let response = client.put(&url).json(&body).send().await?; + let response = client.put(&url).send().await?; print_message_response(response, "VM create request sent successfully").await?; } From 5bf7cd971c2d6fac9965bcdc9a070ec91985601b Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Tue, 28 Apr 2026 15:18:16 -0500 Subject: [PATCH 161/189] get vm meta manifest mostly working with odoroboctl Signed-off-by: Caleb Jones --- Cargo.lock | 3 +++ Cargo.toml | 2 ++ odorobo/Cargo.toml | 4 ++-- odorobo/src/actors/http_actor.rs | 3 --- odorobo/src/ch_driver/actor.rs | 8 ++++---- odorobo/src/http_api/vms.rs | 33 +++++--------------------------- odorobo/src/lib.rs | 1 + odorobo/src/messages/vm.rs | 3 ++- odorobo/src/types.rs | 4 ++-- odoroboctl/Cargo.toml | 5 +++++ odoroboctl/src/cli.rs | 31 +++++++++++++++++++++++------- 11 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 odorobo/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2b522be..a7b25ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2939,13 +2939,16 @@ dependencies = [ name = "odoroboctl" version = "0.1.0" dependencies = [ + "bytesize", "chrono", "clap", + "odorobo", "reqwest", "serde", "serde_json", "stable-eyre", "tokio", + "ulid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5d2725a..50129f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ tracing-subscriber = { version = "0.3", features = [ "fmt", "json", ] } +ulid = { version = "1.2", features = ["serde", "uuid"] } +bytesize = { version = "2.3", features = ["serde"] } # our crates diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index 91cee6c..70a6407 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -13,6 +13,8 @@ serde_json = { workspace = true } serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +ulid = { workspace = true } +bytesize = { workspace = true } http-body-util = "0.1" hyper = { version = "1.9", features = ["full"] } @@ -39,9 +41,7 @@ axum-extra = "0.12" tower-http = { version = "0.6", features = ["trace"] } kameo = { version = "0.20", features = ["remote"] } ahash = { version = "0.8", features = ["serde"] } -ulid = { version = "1.2", features = ["serde", "uuid"] } dirs = "6" -bytesize = { version = "2.3", features = ["serde"] } ipnet = {version = "2.12", features = ["serde", "schemars08"]} url = { version = "2.5", features = ["serde"] } async-trait = "0.1" diff --git a/odorobo/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs index 5728d79..53f9ec6 100644 --- a/odorobo/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -1,4 +1,3 @@ -use cloud_hypervisor_client::models::{CpusConfig, MemoryConfig, PayloadConfig, VmConfig}; use kameo::prelude::*; use crate::messages::vm::{ AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, @@ -6,8 +5,6 @@ use crate::messages::vm::{ }; use stable_eyre::{Report, Result}; -use crate::types::CreateVMRequest; - use super::scheduler_actor::SchedulerActor; const EXTERNAL_HTTP_ADDRESS: &str = "0.0.0.0:3000"; diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 2c716f1..e2d979f 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -104,8 +104,8 @@ impl From for VmConfig { fn from(vm: VirtualMachine) -> Self { VmConfig { cpus: Some(CpusConfig { - boot_vcpus: 4, - max_vcpus: 4, + boot_vcpus: vm.data.vcpus as i32, + max_vcpus: vm.data.max_vcpus.unwrap_or(vm.data.vcpus) as i32, kvm_hyperv: Some(false), max_phys_bits: Some(46), nested: Some(false), @@ -115,7 +115,7 @@ impl From for VmConfig { ..Default::default() }), memory: Some(MemoryConfig { - size: 4294967296, + size: vm.data.memory.as_u64() as i64, mergeable: Some(false), hotplug_method: Some("Acpi".to_string()), shared: Some(true), @@ -132,7 +132,7 @@ impl From for VmConfig { disks: Some(vec![ DiskConfig { // todo: the json i was given by cappy had disable_io_uring and disable_aio in this config, but I can't find these. I assume they were just a mistake. - path: Some("/var/lib/odorobo/f43.raw".to_string()), + path: Some(vm.data.image), readonly: Some(false), direct: Some(false), iommu: Some(false), diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index 22285db..5efdd27 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -2,7 +2,7 @@ use crate::{ actors::http_actor::HTTPActor, types::{ - CreateVMRequest, DebugCreateVMRequest, UpdateVMRequest, VMData, VirtualMachine, VMListResponse, VMStatus, VmId + CreateVMRequest, UpdateVMRequest, VMData, VirtualMachine, VMListResponse, VMStatus, VmId }, messages::vm::CreateVM, utils::OdoroboError, }; use aide::axum::{ @@ -20,8 +20,6 @@ pub fn router() -> ApiRouter> { ApiRouter::new() .api_route("/", get(list_vms)) .api_route("/", post(create_vm)) - // undocumented debug route, do not use in prod - .route("/", axum::routing::put(debug_create_vm)) .api_route("/{vmid}", get(vm_info)) .api_route("/{vmid}", patch(update_vm)) .api_route("/{vmid}", delete(delete_vm)) @@ -50,34 +48,13 @@ async fn create_vm( Json(request): Json, ) -> Result { let message = CreateVM { - vmid: request.data.data.id, - config: request.data.clone(), + vmid: request.vm.data.id, + config: request.vm, }; - let _reply = state.ask(message).await?; + let reply = state.ask(message).await?; - Ok(Json(request)) -} - -async fn debug_create_vm( - State(state): State>, -) -> Result { - let ulid = ulid::Ulid::new(); - let message = CreateVM { - vmid: ulid, - config: Default::default(), - }; - - let _reply = state.ask(message).await?; - - Ok(Json(VirtualMachine { - status: VMStatus::Provisioning, - data: VMData { - id: ulid, - ..Default::default() - }, - ..Default::default() - })) + Ok(Json(reply)) } async fn delete_vm( diff --git a/odorobo/src/lib.rs b/odorobo/src/lib.rs new file mode 100644 index 0000000..cd40856 --- /dev/null +++ b/odorobo/src/lib.rs @@ -0,0 +1 @@ +pub mod types; diff --git a/odorobo/src/messages/vm.rs b/odorobo/src/messages/vm.rs index 46f08f5..512a793 100644 --- a/odorobo/src/messages/vm.rs +++ b/odorobo/src/messages/vm.rs @@ -1,6 +1,7 @@ //! VM-related messages use cloud_hypervisor_client::models::VmConfig; use kameo::prelude::*; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ulid::Ulid; @@ -25,7 +26,7 @@ pub struct CreateVM { pub config: VirtualMachine, } -#[derive(Serialize, Deserialize, Reply, Debug)] +#[derive(Serialize, Deserialize, Reply, Debug, JsonSchema)] pub struct CreateVMReply { pub config: Option, } diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index d799f3b..2d340ac 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -66,7 +66,7 @@ impl Default for StorageUri { #[derive(Serialize, Deserialize, Debug, JsonSchema, Default, Clone)] pub struct CreateVMRequest { /// Data of the VM to create - pub data: VirtualMachine, + pub vm: VirtualMachine, /// Whether to boot the VM immediately after creation pub boot: bool, } @@ -167,7 +167,7 @@ pub struct VirtualMachine { pub metadata: Option, /// List of Affinity rules for scheduling. These are ANDed / summed together depending on the strictness. - pub affinity: Vec + pub affinity: Option> } diff --git a/odoroboctl/Cargo.toml b/odoroboctl/Cargo.toml index 22be64c..75119ec 100644 --- a/odoroboctl/Cargo.toml +++ b/odoroboctl/Cargo.toml @@ -11,4 +11,9 @@ tokio = { workspace = true, features = ["full"] } reqwest = { workspace = true, features = ["json"] } serde_json = { workspace = true } serde = { workspace = true, features = ["derive"] } +ulid = { workspace = true } +bytesize = { workspace = true } + chrono = { version = "0.4", features = ["serde"] } + +odorobo = { workspace = true } diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 28cada4..d551737 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -4,6 +4,9 @@ use clap::{Parser, Subcommand}; use reqwest::{Client, Response}; use serde::{Deserialize, Serialize}; use stable_eyre::Result; +use odorobo::types::{CreateVMRequest, VMData, VirtualMachine}; +use ulid::Ulid; +use bytesize::ByteSize; #[derive(Parser)] #[command( @@ -45,12 +48,6 @@ pub enum Command { }, } -#[derive(Debug, Serialize)] -struct DebugCreateVMRequest { - vm_config: serde_json::Value, - boot: bool, -} - #[derive(Debug, Deserialize)] #[serde(transparent)] struct VmId(String); @@ -102,8 +99,28 @@ pub async fn run_command(cli: Cli) -> Result<()> { match cli.command { Command::Create => { + let vm = VirtualMachine { + data: VMData { + id: Ulid::new(), + name: "test_vm".to_string(), + vcpus: 4, + max_vcpus: None, + memory: ByteSize::gib(4), + image: "/var/lib/odorobo/f43.raw".to_string(), + ..Default::default() + }, + ..Default::default() + }; + + let request = CreateVMRequest { + vm, + boot: true + }; + let url = format!("{}/vms", base_url); - let response = client.put(&url).send().await?; + let response = client.post(&url).json(&request).send().await?; + + println!("{:?}", response.url()); print_message_response(response, "VM create request sent successfully").await?; } From 33135c450de9e9a3bdd67bc7ebad88716206f5f9 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Tue, 28 Apr 2026 15:20:44 -0500 Subject: [PATCH 162/189] fix a few warnings Signed-off-by: Caleb Jones --- odorobo/src/http_api/vms.rs | 2 +- odoroboctl/src/cli.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index 5efdd27..6e6b2fb 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -2,7 +2,7 @@ use crate::{ actors::http_actor::HTTPActor, types::{ - CreateVMRequest, UpdateVMRequest, VMData, VirtualMachine, VMListResponse, VMStatus, VmId + CreateVMRequest, UpdateVMRequest, VirtualMachine, VMListResponse, VmId }, messages::vm::CreateVM, utils::OdoroboError, }; use aide::axum::{ diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index d551737..e2fd835 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -1,8 +1,6 @@ -use std::path::PathBuf; - use clap::{Parser, Subcommand}; use reqwest::{Client, Response}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize}; use stable_eyre::Result; use odorobo::types::{CreateVMRequest, VMData, VirtualMachine}; use ulid::Ulid; From 5ecc31266b7d0d901216cca405ba7a1c823c1925 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Tue, 28 Apr 2026 20:26:40 -0500 Subject: [PATCH 163/189] address most of cappy's feedback Signed-off-by: Caleb Jones --- odorobo/src/ch_driver/actor.rs | 18 +++--------------- odorobo/src/types.rs | 1 + odoroboctl/src/cli.rs | 2 +- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index e2d979f..6e9395a 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -100,6 +100,7 @@ impl Actor for VMActor { } } +// todo: improve a lot of these config options. most of them should be set by the manifest impl From for VmConfig { fn from(vm: VirtualMachine) -> Self { VmConfig { @@ -126,7 +127,6 @@ impl From for VmConfig { }), payload: PayloadConfig { firmware: Some("/var/lib/odorobo/CLOUDHV.fd".to_string()), - cmdline: Some("odorobo.test=1".to_string()), ..Default::default() }, disks: Some(vec![ @@ -160,20 +160,8 @@ impl From for VmConfig { iommu: Some(false) } ), - serial: Some( - ConsoleConfig { - mode: ConsoleMode::Null, - iommu: Some(false), - ..Default::default() - } - ), - debug_console: Some( - DebugConsoleConfig { - mode: ConsoleMode::Off, - iobase: Some(233), - ..Default::default() - } - ), + serial: None, + debug_console: None, iommu: Some(false), watchdog: Some(false), pvpanic: Some(false), diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index 2d340ac..b526cf8 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -212,6 +212,7 @@ pub enum MetadataTable { Annotation } +// todo: possibly replace with std::ops #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum Operator { In, diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index e2fd835..7e3cec3 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -96,7 +96,7 @@ pub async fn run_command(cli: Cli) -> Result<()> { let base_url = cli.manager_addr; match cli.command { - Command::Create => { + Command::Create => { // TODO: setup actual cli args for these parameters. or just take in arbitrary json and serialize it into a VirtualMachine. let vm = VirtualMachine { data: VMData { id: Ulid::new(), From 5b666b17dc70347ce96bfcf03b87cdcc4108b3e4 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Wed, 29 Apr 2026 12:44:10 -0500 Subject: [PATCH 164/189] resolve some of cappy's feedback. Signed-off-by: Caleb Jones --- odorobo/src/ch_driver/actor.rs | 16 ++-------------- odoroboctl/src/cli.rs | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 6e9395a..387dc83 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -1,5 +1,5 @@ use crate::{ch_driver::VMInstance, types::VirtualMachine}; -use cloud_hypervisor_client::models::{ConsoleConfig, ConsoleMode, CpuFeatures, CpusConfig, DebugConsoleConfig, DiskConfig, ImageType, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, RngConfig, VmConfig}; +use cloud_hypervisor_client::models::{CpuFeatures, CpusConfig, DiskConfig, ImageType, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, VmConfig}; use kameo::prelude::*; use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, @@ -108,7 +108,6 @@ impl From for VmConfig { boot_vcpus: vm.data.vcpus as i32, max_vcpus: vm.data.max_vcpus.unwrap_or(vm.data.vcpus) as i32, kvm_hyperv: Some(false), - max_phys_bits: Some(46), nested: Some(false), features: Some(CpuFeatures { amx: Some(false) @@ -130,7 +129,7 @@ impl From for VmConfig { ..Default::default() }, disks: Some(vec![ - DiskConfig { + DiskConfig { // todo: get cappy to make this auto generate this via the manifest's volumes atribute. // todo: the json i was given by cappy had disable_io_uring and disable_aio in this config, but I can't find these. I assume they were just a mistake. path: Some(vm.data.image), readonly: Some(false), @@ -154,17 +153,6 @@ impl From for VmConfig { ..Default::default() } ]), - rng: Some( - RngConfig { - src: "/dev/urandom".to_string(), - iommu: Some(false) - } - ), - serial: None, - debug_console: None, - iommu: Some(false), - watchdog: Some(false), - pvpanic: Some(false), platform: Some(PlatformConfig { serial_number: Some("ds=nocloud".to_string()), ..Default::default() diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index 7e3cec3..b769271 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -26,7 +26,7 @@ pub struct Cli { #[derive(Subcommand)] pub enum Command { - /// Create a VM via the scheduler debug endpoint, + /// Create a VM via the scheduler endpoint, /// optionally also booting it immediately after creation (if `--boot` is specified). Create, From 6acc9ae53ba3608ba4af3f49ee182e300591bc76 Mon Sep 17 00:00:00 2001 From: Cappy Ishihara Date: Thu, 30 Apr 2026 09:24:20 +0700 Subject: [PATCH 165/189] infer from ch defaults --- odorobo/src/ch_driver/actor.rs | 37 +++++++--------------------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 387dc83..4586c87 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -107,21 +107,10 @@ impl From for VmConfig { cpus: Some(CpusConfig { boot_vcpus: vm.data.vcpus as i32, max_vcpus: vm.data.max_vcpus.unwrap_or(vm.data.vcpus) as i32, - kvm_hyperv: Some(false), - nested: Some(false), - features: Some(CpuFeatures { - amx: Some(false) - }), ..Default::default() }), memory: Some(MemoryConfig { size: vm.data.memory.as_u64() as i64, - mergeable: Some(false), - hotplug_method: Some("Acpi".to_string()), - shared: Some(true), - hugepages: Some(false), - prefault: Some(false), - thp: Some(true), ..Default::default() }), payload: PayloadConfig { @@ -130,34 +119,22 @@ impl From for VmConfig { }, disks: Some(vec![ DiskConfig { // todo: get cappy to make this auto generate this via the manifest's volumes atribute. - // todo: the json i was given by cappy had disable_io_uring and disable_aio in this config, but I can't find these. I assume they were just a mistake. path: Some(vm.data.image), - readonly: Some(false), - direct: Some(false), - iommu: Some(false), - num_queues: Some(1), - queue_size: Some(128), - vhost_user: Some(false), - id: Some("_disk0".to_string()), - pci_segment: Some(0), - backing_files: Some(false), - sparse: Some(true), image_type: Some(ImageType::Raw), ..Default::default() } ]), - net: Some(vec![ - NetConfig { - id: Some("net://devnet".to_string()), - mac: Some("46:59:52:67:67:67".to_string()), - ..Default::default() - } - ]), + // todo: generate from VM network field + // net: Some(vec![ + // NetConfig { + // id: Some("net://devnet".to_string()), + // ..Default::default() + // } + // ]), platform: Some(PlatformConfig { serial_number: Some("ds=nocloud".to_string()), ..Default::default() }), - landlock_enable: Some(false), ..Default::default() } } From d7ed25f024d9b4ed4ace370c0a1f418c8bbed9d3 Mon Sep 17 00:00:00 2001 From: Caleb Jones Date: Fri, 1 May 2026 11:39:40 -0500 Subject: [PATCH 166/189] resolve unused imports Signed-off-by: Caleb Jones --- odorobo/src/ch_driver/actor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 4586c87..414b00b 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -1,5 +1,5 @@ use crate::{ch_driver::VMInstance, types::VirtualMachine}; -use cloud_hypervisor_client::models::{CpuFeatures, CpusConfig, DiskConfig, ImageType, MemoryConfig, NetConfig, PayloadConfig, PlatformConfig, VmConfig}; +use cloud_hypervisor_client::models::{CpusConfig, DiskConfig, ImageType, MemoryConfig, PayloadConfig, PlatformConfig, VmConfig}; use kameo::prelude::*; use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, From ac2b66a3395af6a7b19d79120fbce450e3d99f0d Mon Sep 17 00:00:00 2001 From: madonuko Date: Sun, 5 Jul 2026 17:05:30 +0800 Subject: [PATCH 167/189] feat: create lockfile on startup Close #30 --- odorobo/Cargo.toml | 5 ++--- odorobo/src/main.rs | 12 +++++------- odorobo/src/utils/lockfile.rs | 27 +++++++++++++++++++++++++++ odorobo/src/utils/mod.rs | 34 +++++++++++++++------------------- 4 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 odorobo/src/utils/lockfile.rs diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index 91cee6c..cecda2f 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -6,7 +6,7 @@ license = "AGPL-3.0-or-later" [dependencies] stable-eyre = { workspace = true } -clap = { workspace = true, features = ["derive", "env"] } +clap = { workspace = true, features = ["derive", "env"] } tokio = { workspace = true, features = ["full"] } reqwest = { workspace = true, features = ["json"] } serde_json = { workspace = true } @@ -42,7 +42,7 @@ ahash = { version = "0.8", features = ["serde"] } ulid = { version = "1.2", features = ["serde", "uuid"] } dirs = "6" bytesize = { version = "2.3", features = ["serde"] } -ipnet = {version = "2.12", features = ["serde", "schemars08"]} +ipnet = { version = "2.12", features = ["serde", "schemars08"] } url = { version = "2.5", features = ["serde"] } async-trait = "0.1" dashmap = "6.1" @@ -63,5 +63,4 @@ aide = { version = "0.15", features = [ schemars = "0.9" - cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index 350843f..4a4c359 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -1,12 +1,12 @@ pub mod actors; mod ch_api; +pub mod config; pub mod http_api; +pub mod messages; pub mod networking; mod state; -mod utils; -pub mod messages; -pub mod config; pub mod types; +mod utils; use std::fs; @@ -14,15 +14,16 @@ use clap::Parser; use kameo::actor::Spawn; use stable_eyre::Result; +use crate::actors::agent_actor::AgentActor; use crate::actors::http_actor::HTTPActor; use crate::actors::scheduler_actor::SchedulerActor; use crate::config::Config; use crate::utils::actor_names::{HTTP_API_SERVER, SCHEDULER}; use crate::utils::{actor_names::AGENT, connect_to_swarm, init}; -use crate::actors::agent_actor::AgentActor; #[tokio::main] async fn main() -> Result<()> { + let _ = utils::lockfile::init_lockfile(); let cli_config = config::CliConfig::parse(); // TODO: ask infra team where they want this on the box let config: Config = if let Ok(file) = fs::File::open("config.json") { @@ -35,11 +36,9 @@ async fn main() -> Result<()> { tracing::info!(?config, "Starting odorobo"); - let local_peer_id = connect_to_swarm().unwrap(); tracing::info!(?local_peer_id, "Peer ID"); - // start agents let agent_actor = AgentActor::spawn(config.clone()); agent_actor.register(AGENT).await?; @@ -55,7 +54,6 @@ async fn main() -> Result<()> { http_actor.wait_for_shutdown().await; } - agent_actor.wait_for_shutdown().await; Ok(()) diff --git a/odorobo/src/utils/lockfile.rs b/odorobo/src/utils/lockfile.rs new file mode 100644 index 0000000..1890f75 --- /dev/null +++ b/odorobo/src/utils/lockfile.rs @@ -0,0 +1,27 @@ +use tokio::io::AsyncWriteExt; + +const LOCKFILE: &str = "/var/lock/odorobo.lock"; + +// `Option` needed since `std::mem::take` requires `impl Default` +pub struct Lockfile(Option); + +impl Drop for Lockfile { + fn drop(&mut self) { + std::mem::drop(std::mem::take(&mut self.0).expect("None in Lockfile")); + _ = std::fs::remove_file(LOCKFILE) + .inspect_err(|e| tracing::warn!(?LOCKFILE, ?e, "cannot remove lockfile")); + } +} + +/// Create an odorobo lockfile +/// +/// See #30, only 1 instance of odorobo should run. +pub async fn init_lockfile() -> Result { + let mut f = tokio::fs::File::create_new(LOCKFILE) + .await + .map_err(|e| format!("Cannot create lockfile at {LOCKFILE}: {e:?}"))?; + f.write_all(&std::process::id().to_ne_bytes()) + .await + .map_err(|e| format!("cannot write to {LOCKFILE}: {e:?}"))?; + Ok(Lockfile(Some(f))) +} diff --git a/odorobo/src/utils/mod.rs b/odorobo/src/utils/mod.rs index 01c7125..f85de5f 100644 --- a/odorobo/src/utils/mod.rs +++ b/odorobo/src/utils/mod.rs @@ -1,17 +1,18 @@ -pub mod actor_names; pub mod actor_cache; +pub mod actor_names; +pub mod lockfile; use aide::OperationIo; -use stable_eyre::{Result, Report}; -use tracing::level_filters::LevelFilter; -use tracing_subscriber::EnvFilter; -use thiserror::Error; +use api_error::ApiError; use kameo::prelude::*; use libp2p::futures::StreamExt; use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::{PeerId, mdns, noise, tcp, yamux}; +use stable_eyre::{Report, Result}; +use thiserror::Error; +use tracing::level_filters::LevelFilter; use tracing::{debug, error, info, trace, warn}; -use api_error::ApiError; +use tracing_subscriber::EnvFilter; // todo: wrap with axum-responses, return this type on request failure #[derive(Error, Debug, ApiError, OperationIo)] @@ -26,21 +27,18 @@ impl From> for OdoroboError { fn from(value: kameo::error::SendError) -> Self { let kameo_error = value.to_string(); error!(?value); - OdoroboError::Report(value.err().unwrap_or_else(|| { - Report::msg(format!("could not unwrap kameo error: {kameo_error}")) - })) + OdoroboError::Report( + value.err().unwrap_or_else(|| { + Report::msg(format!("could not unwrap kameo error: {kameo_error}")) + }), + ) } } #[cfg(test)] mod tests { use super::*; - use axum::{ - http::StatusCode, - body::Body, http::Request, - routing::get, - Router, - }; + use axum::{Router, body::Body, http::Request, http::StatusCode, routing::get}; use http_body_util::BodyExt; use tower::ServiceExt; @@ -50,7 +48,8 @@ mod tests { #[tokio::test] async fn test_error() { - let response = Router::new().route("/", get(handler)) + let response = Router::new() + .route("/", get(handler)) .oneshot(Request::get("/").body(Body::empty()).unwrap()) .await .unwrap(); @@ -64,7 +63,6 @@ mod tests { } } - pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); @@ -110,8 +108,6 @@ pub fn init_default() -> Result<()> { init(None) } - - #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { kameo: remote::Behaviour, From e7f08f590db00b3d4646ba7fba686a37097a4c32 Mon Sep 17 00:00:00 2001 From: madonuko Date: Sun, 5 Jul 2026 18:11:17 +0800 Subject: [PATCH 168/189] feat: watch for termsigs --- Cargo.lock | 11 +++++++++++ odorobo/Cargo.toml | 1 + odorobo/src/main.rs | 31 ++++++++++++++++++++++++++++--- odorobo/src/utils/lockfile.rs | 27 +++++++++++++++++++++------ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a573c2..8265cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2922,6 +2922,7 @@ dependencies = [ "schemars 0.9.0", "serde", "serde_json", + "signal-hook", "stable-eyre", "sysinfo", "thiserror 2.0.18", @@ -3958,6 +3959,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index cecda2f..9890606 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -64,3 +64,4 @@ schemars = "0.9" cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } +signal-hook = "0.4.4" diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index 4a4c359..85f209f 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -9,6 +9,8 @@ pub mod types; mod utils; use std::fs; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use clap::Parser; use kameo::actor::Spawn; @@ -21,9 +23,7 @@ use crate::config::Config; use crate::utils::actor_names::{HTTP_API_SERVER, SCHEDULER}; use crate::utils::{actor_names::AGENT, connect_to_swarm, init}; -#[tokio::main] -async fn main() -> Result<()> { - let _ = utils::lockfile::init_lockfile(); +fn main() -> Result<()> { let cli_config = config::CliConfig::parse(); // TODO: ask infra team where they want this on the box let config: Config = if let Ok(file) = fs::File::open("config.json") { @@ -33,7 +33,32 @@ async fn main() -> Result<()> { }; init(Some("odorobo"))?; + let term = utils::lockfile::register_termsigs()?; + let _lock = utils::lockfile::init_lockfile() + .map_err(|e| stable_eyre::eyre::eyre!("cannot init lockfile").wrap_err(e))?; + mainloop(term, cli_config, config) +} + +fn mainloop(term: Arc, cli_config: config::CliConfig, config: Config) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("can't build tokio"); + let handle = runtime.spawn(inner_main(cli_config, config)); + + loop { + if handle.is_finished() { + break runtime.block_on(handle).expect("cannot join main thread"); + } + if term.load(Ordering::Relaxed) { + handle.abort(); + stable_eyre::eyre::bail!("Exit due to termination signal"); + } + } +} + +async fn inner_main(cli_config: config::CliConfig, config: Config) -> Result<()> { tracing::info!(?config, "Starting odorobo"); let local_peer_id = connect_to_swarm().unwrap(); diff --git a/odorobo/src/utils/lockfile.rs b/odorobo/src/utils/lockfile.rs index 1890f75..cd70447 100644 --- a/odorobo/src/utils/lockfile.rs +++ b/odorobo/src/utils/lockfile.rs @@ -1,12 +1,13 @@ -use tokio::io::AsyncWriteExt; +use std::io::Write; const LOCKFILE: &str = "/var/lock/odorobo.lock"; // `Option` needed since `std::mem::take` requires `impl Default` -pub struct Lockfile(Option); +pub struct Lockfile(Option); impl Drop for Lockfile { fn drop(&mut self) { + tracing::debug!("dropping Lockfile"); std::mem::drop(std::mem::take(&mut self.0).expect("None in Lockfile")); _ = std::fs::remove_file(LOCKFILE) .inspect_err(|e| tracing::warn!(?LOCKFILE, ?e, "cannot remove lockfile")); @@ -16,12 +17,26 @@ impl Drop for Lockfile { /// Create an odorobo lockfile /// /// See #30, only 1 instance of odorobo should run. -pub async fn init_lockfile() -> Result { - let mut f = tokio::fs::File::create_new(LOCKFILE) - .await +pub fn init_lockfile() -> Result { + tracing::trace!("creating lockfile at {LOCKFILE}"); + let mut f = std::fs::File::create_new(LOCKFILE) .map_err(|e| format!("Cannot create lockfile at {LOCKFILE}: {e:?}"))?; f.write_all(&std::process::id().to_ne_bytes()) - .await .map_err(|e| format!("cannot write to {LOCKFILE}: {e:?}"))?; + f.flush() + .map_err(|e| format!("cannot flush {LOCKFILE}: {e:?}"))?; Ok(Lockfile(Some(f))) } + +/// Register termination signals to watch +/// +/// We need to tidy up lockfiles before exiting. Watching these signals can give us a chance to +/// actually clean things up. +pub fn register_termsigs() -> std::io::Result> { + let term = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + for sig in signal_hook::consts::TERM_SIGNALS { + signal_hook::flag::register_conditional_shutdown(*sig, 1, std::sync::Arc::clone(&term))?; + signal_hook::flag::register(*sig, std::sync::Arc::clone(&term))?; + } + Ok(term) +} From 228bd4ec9aad5f7604353e18d15eb3031c9602bc Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 14:45:05 -0600 Subject: [PATCH 169/189] update dev container --- .devcontainer/Dockerfile | 6 ++--- .devcontainer/devcontainer.json | 48 ++++++++++++++++----------------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b512bc9..a4e670f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ultramarine-linux/ultramarine:43 +FROM ghcr.io/ultramarine-linux/ultramarine:44 # Install dependencies RUN dnf do -y --action=install \ @@ -10,6 +10,6 @@ RUN dnf do -y --action=install \ git \ zig \ cloud-hypervisor-nightly - + RUN mkdir -p /run/odorobo/vms -RUN chmod -R 1777 /run/odorobo \ No newline at end of file +RUN chmod -R 1777 /run/odorobo diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2bfa72d..aa7041b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,34 +1,32 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile { - "features": { - "ghcr.io/devcontainers/features/rust:1": {}, - }, - "name": "Dev", - "build": { - // Sets the run context to one level up instead of the .devcontainer folder. - "context": ".", - // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. - "dockerfile": "Dockerfile" - }, - // required for netlink and other privileged operations - "privileged": true, - "capAdd": [ - "NET_ADMIN" - ], + "features": { + "ghcr.io/devcontainers/features/rust:1": {}, + }, + "name": "Dev", + "build": { + // Sets the run context to one level up instead of the .devcontainer folder. + "context": ".", + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerfile": "Dockerfile", + }, + // required for netlink and other privileged operations + "privileged": true, + "capAdd": ["NET_ADMIN"], - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], - // Uncomment the next line to run commands after the container is created. - // "postCreateCommand": "cat /etc/os-release", + // Uncomment the next line to run commands after the container is created. + // "postCreateCommand": "cat /etc/os-release", - // Configure tool-specific properties. - // "customizations": {}, + // Configure tool-specific properties. + // "customizations": {}, - // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "devcontainer" + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" } From aa96565030df9188678c274cd163b2f95da8d51d Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 14:48:57 -0600 Subject: [PATCH 170/189] add codeberg mirror --- .github/workflows/mirror.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/mirror.yml diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..9b86fa3 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,16 @@ +name: mirror + +on: [push] + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: yesolutions/mirror-action@master + with: + REMOTE: "https://codeberg.org/FyraStack/odorobo.git" + GIT_USERNAME: ${{ secrets.GIT_USERNAME }} + GIT_PASSWORD: ${{ secrets.GIT_PASSWORD }} From 6271194beeb369945e6adc666f7f169975e45213 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 16:14:53 -0600 Subject: [PATCH 171/189] fix rust checks --- .github/workflows/clippy.yml | 60 ------------------------------------ .github/workflows/rust.yml | 12 ++++++++ 2 files changed, 12 insertions(+), 60 deletions(-) delete mode 100644 .github/workflows/clippy.yml create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml deleted file mode 100644 index 772664f..0000000 --- a/.github/workflows/clippy.yml +++ /dev/null @@ -1,60 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# rust-clippy is a tool that runs a bunch of lints to catch common -# mistakes in your Rust code and help improve your Rust code. -# More details at https://github.com/rust-lang/rust-clippy -# and https://rust-lang.github.io/rust-clippy/ - -name: rust-clippy analyze - -on: - push: - pull_request: - schedule: - - cron: "39 6 * * 6" - -env: - CARGO_TERM_COLOR: always - SCCACHE_GHA_ENABLED: "true" - RUSTC_WRAPPER: "sccache" - -jobs: - rust-clippy-analyze: - name: Run rust-clippy analyzing - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 - with: - profile: minimal - toolchain: stable - components: clippy - override: true - - - uses: rui314/setup-mold@v1 - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Install required cargo - run: cargo install clippy-sarif sarif-fmt - - - name: Run rust-clippy - run: cargo clippy - --all-features - --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt - continue-on-error: true - - - name: Upload analysis results to GitHub - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: rust-clippy-results.sarif - wait-for-processing: true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..ac33dfb --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,12 @@ +name: Rust +permissions: + contents: read +on: + push: + +jobs: + rust: + permissions: + security-events: write + contents: read + uses: FyraLabs/actions/.github/workflows/rust.yml@main From 1ebdbed938aed112ef2f4055f62aeee321ebb987 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 16:22:52 -0600 Subject: [PATCH 172/189] these are slightly different --- .github/workflows/build_riscv_rva23.yml | 47 ------- .github/workflows/build_x86.yml | 44 ------- .github/workflows/rust.yml | 159 +++++++++++++++++++++++- 3 files changed, 156 insertions(+), 94 deletions(-) delete mode 100644 .github/workflows/build_riscv_rva23.yml delete mode 100644 .github/workflows/build_x86.yml diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml deleted file mode 100644 index 1183270..0000000 --- a/.github/workflows/build_riscv_rva23.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Build RISC V RVA23 via x86 cross-compile - -on: - push: - pull_request: - -permissions: - contents: read - actions: read - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Build image - run: podman build -t odorobo-builder:rva23 . - - name: Build binaries - run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose - - - - name: Upload artifact - agent - uses: actions/upload-artifact@v7 - with: - archive: false - name: odorobo-agent-riscv_rva23 - path: | - target/riscv64a23-unknown-linux-gnu/release/odorobo-agent - - name: Upload artifact - odoroboctl - uses: actions/upload-artifact@v7 - with: - archive: false - name: odoroboctl-riscv_rva23 - path: | - target/riscv64a23-unknown-linux-gnu/release/odoroboctl diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml deleted file mode 100644 index 7eda6d6..0000000 --- a/.github/workflows/build_x86.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build x86 - -on: - push: - pull_request: - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - SCCACHE_GHA_ENABLED: "true" - RUSTC_WRAPPER: "sccache" - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - uses: rui314/setup-mold@v1 - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Build - run: cargo build --verbose --release - - name: Upload artifact - agent - uses: actions/upload-artifact@v7 - with: - archive: false - name: odorobo-agent-x86 - path: | - target/release/odorobo-agent - - - name: Upload artifact - odoroboctl - uses: actions/upload-artifact@v7 - with: - archive: false - name: odoroboctl-x86 - path: | - target/release/odoroboctl - - - name: Run tests - run: cargo test --verbose diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ac33dfb..312baf0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -3,10 +3,163 @@ permissions: contents: read on: push: + workflow_call: + inputs: + test-runs-on: + description: "The runner type which the test job runs on" + required: false + default: ubuntu-latest + type: string + test-container: + description: "The container configuration for the test job, in JSON" + required: false + default: "null" + type: string + test-pre: + description: "Command to run before tests are executed, useful for installing system deps" + required: false + default: "" + type: string + test-cargo-flags: + description: "Additional flags to pass to cargo commands in the test step, useful for specifying features" + required: false + default: "" + type: string + +env: + CARGO_TERM_COLOR: always + CARGO_TERM_VERBOSE: true jobs: - rust: + rustfmt: + if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 + with: + toolchain: stable + components: rustfmt + + - name: Run rustfmt + run: cargo fmt -- --check + clippy: permissions: security-events: write - contents: read - uses: FyraLabs/actions/.github/workflows/rust.yml@main + if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 + with: + toolchain: stable + components: clippy + + - name: Install required cargo + run: cargo install clippy-sarif sarif-fmt + + - name: Run rust-clippy + run: cargo clippy + --all-features + --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt + continue-on-error: true + env: + CARGO_TERM_VERBOSE: false + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: rust-clippy-results.sarif + wait-for-processing: true + test-x86: + if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) + runs-on: ${{ inputs.test-runs-on }} + container: ${{ fromJSON(inputs.test-container) }} + steps: + - name: Run pre script + run: ${{ inputs.test-pre }} + if: ${{ inputs.test-pre != '' }} + + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 + with: + toolchain: stable + + - name: Fetch + run: cargo fetch --locked + + - name: Build + run: cargo build --verbose --release + + - name: Upload artifact - agent + uses: actions/upload-artifact@v7 + with: + archive: false + name: odorobo-agent-x86 + path: | + target/release/odorobo-agent + + - name: Upload artifact - odoroboctl + uses: actions/upload-artifact@v7 + with: + archive: false + name: odoroboctl-x86 + path: | + target/release/odoroboctl + + - name: Run tests + run: cargo test --verbose + + test-riscv-rva23: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build image + run: podman build -t odorobo-builder:rva23 . + + - name: Build binaries + run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose + + - name: Upload artifact - agent + uses: actions/upload-artifact@v7 + with: + archive: false + name: odorobo-agent-riscv_rva23 + path: | + target/riscv64a23-unknown-linux-gnu/release/odorobo-agent + + - name: Upload artifact - odoroboctl + uses: actions/upload-artifact@v7 + with: + archive: false + name: odoroboctl-riscv_rva23 + path: | + target/riscv64a23-unknown-linux-gnu/release/odoroboctl + + - name: Test + run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo test --verbose From eb00317a2389696c33d3be83c6dcdaed6aecce3f Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 16:27:08 -0600 Subject: [PATCH 173/189] fixed missing inputs --- .github/workflows/rust.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 312baf0..0b9c882 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -83,13 +83,8 @@ jobs: wait-for-processing: true test-x86: if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) - runs-on: ${{ inputs.test-runs-on }} - container: ${{ fromJSON(inputs.test-container) }} + runs-on: ubuntu-latest steps: - - name: Run pre script - run: ${{ inputs.test-pre }} - if: ${{ inputs.test-pre != '' }} - - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: From 9325a747166a5309a9386c1de217180ddded808a Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Mon, 6 Jul 2026 16:27:23 -0600 Subject: [PATCH 174/189] remove unnecessary inputs --- .github/workflows/rust.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0b9c882..47301d9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -4,27 +4,6 @@ permissions: on: push: workflow_call: - inputs: - test-runs-on: - description: "The runner type which the test job runs on" - required: false - default: ubuntu-latest - type: string - test-container: - description: "The container configuration for the test job, in JSON" - required: false - default: "null" - type: string - test-pre: - description: "Command to run before tests are executed, useful for installing system deps" - required: false - default: "" - type: string - test-cargo-flags: - description: "Additional flags to pass to cargo commands in the test step, useful for specifying features" - required: false - default: "" - type: string env: CARGO_TERM_COLOR: always From 186d4d045099dcb55eb0afc7c4ed1de2d3c95cc4 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 7 Jul 2026 15:03:42 -0600 Subject: [PATCH 175/189] add dependabot for actions, tag commit, permissions for mirror --- .github/dependabot.yml | 8 ++++++++ .github/workflows/mirror.yml | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..716ecdd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + # Maintain GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 \ No newline at end of file diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 9b86fa3..16b4bd8 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,4 +1,6 @@ name: mirror +permissions: + contents: read on: [push] @@ -6,7 +8,7 @@ jobs: mirror: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - uses: yesolutions/mirror-action@master From 89d508d292377bf987a9377dfb261114d2fe9198 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:04:29 +0000 Subject: [PATCH 176/189] Bump mozilla-actions/sccache-action from 0.0.9 to 0.0.10 Bumps [mozilla-actions/sccache-action](https://github.com/mozilla-actions/sccache-action) from 0.0.9 to 0.0.10. - [Release notes](https://github.com/mozilla-actions/sccache-action/releases) - [Commits](https://github.com/mozilla-actions/sccache-action/compare/v0.0.9...v0.0.10) --- updated-dependencies: - dependency-name: mozilla-actions/sccache-action dependency-version: 0.0.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build_x86.yml | 2 +- .github/workflows/clippy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 7eda6d6..582151a 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - uses: rui314/setup-mold@v1 - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Build run: cargo build --verbose --release diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 772664f..0b306e3 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -42,7 +42,7 @@ jobs: - uses: rui314/setup-mold@v1 - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Install required cargo run: cargo install clippy-sarif sarif-fmt From 36d59c0663575ce36604e7b0cd89cfab53c0e558 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:04:38 +0000 Subject: [PATCH 177/189] Bump actions/cache from 5 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build_riscv_rva23.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index 1183270..073eb15 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/cache@v5 + - uses: actions/cache@v6 with: path: | ~/.cargo/bin/ From 7615f0cca06333efeca2659e30bdac6ea1c79336 Mon Sep 17 00:00:00 2001 From: Cypress Reed Date: Tue, 7 Jul 2026 15:32:23 -0600 Subject: [PATCH 178/189] tag commit --- .github/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 16b4bd8..ff555e2 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - uses: yesolutions/mirror-action@master + - uses: yesolutions/mirror-action@1708f16cdb28634fd3ba10c5c79abc91f5578a14 # v0.7.0 with: REMOTE: "https://codeberg.org/FyraStack/odorobo.git" GIT_USERNAME: ${{ secrets.GIT_USERNAME }} From 7a6434f9aaef1af13030b98b730a9cdaaec092a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:05:28 +0000 Subject: [PATCH 179/189] Bump actions/checkout from 4 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build_riscv_rva23.yml | 2 +- .github/workflows/build_x86.yml | 2 +- .github/workflows/clippy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_riscv_rva23.yml b/.github/workflows/build_riscv_rva23.yml index 073eb15..0e9ba51 100644 --- a/.github/workflows/build_riscv_rva23.yml +++ b/.github/workflows/build_riscv_rva23.yml @@ -15,7 +15,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/cache@v6 with: path: | diff --git a/.github/workflows/build_x86.yml b/.github/workflows/build_x86.yml index 7eda6d6..ecc113c 100644 --- a/.github/workflows/build_x86.yml +++ b/.github/workflows/build_x86.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: rui314/setup-mold@v1 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 772664f..1313817 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -30,7 +30,7 @@ jobs: actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 From 2b357a6b7f496b56163963b20a010b6f79f502b3 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 17:10:49 -0600 Subject: [PATCH 180/189] switch to tagged commits --- .github/workflows/rust.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 47301d9..c7a68a0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -102,8 +102,8 @@ jobs: test-riscv-rva23: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/cache@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | ~/.cargo/bin/ @@ -120,7 +120,7 @@ jobs: run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo build --release --verbose - name: Upload artifact - agent - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: archive: false name: odorobo-agent-riscv_rva23 @@ -128,7 +128,7 @@ jobs: target/riscv64a23-unknown-linux-gnu/release/odorobo-agent - name: Upload artifact - odoroboctl - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: archive: false name: odoroboctl-riscv_rva23 From a4947160a915948688026efc4fbae265385c1d2b Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 17:15:44 -0600 Subject: [PATCH 181/189] add comment --- .github/workflows/rust.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fdff83e..d3678c0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,3 +1,5 @@ +# This is based on the normal Fyra Rust workflow, but modified to add RVA23 support. See https://github.com/FyraLabs/actions for more info. + name: Rust permissions: contents: read From 537805d996c4821330f64c81d11da09412f2cf3d Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 17:19:15 -0600 Subject: [PATCH 182/189] fix rustfmt issues --- odorobo/src/actors/agent_actor.rs | 44 ++++++--- odorobo/src/actors/http_actor.rs | 8 +- odorobo/src/actors/scheduler_actor.rs | 98 +++++++++---------- odorobo/src/ch_driver/actor.rs | 24 ++--- odorobo/src/ch_driver/instance.rs | 1 - odorobo/src/ch_driver/mod.rs | 4 +- .../src/ch_driver/provisioning/hooks/mod.rs | 4 +- .../src/ch_driver/transform/path_verify.rs | 21 ++-- odorobo/src/config.rs | 7 +- odorobo/src/http_api/mod.rs | 4 +- odorobo/src/http_api/nodes.rs | 4 +- odorobo/src/http_api/vms.rs | 12 ++- odorobo/src/http_api/volumes.rs | 7 +- odorobo/src/main.rs | 13 +-- odorobo/src/messages/agent.rs | 3 +- odorobo/src/messages/mod.rs | 4 +- odorobo/src/networking/actor.rs | 3 +- odorobo/src/types.rs | 16 ++- odorobo/src/utils/actor_cache.rs | 75 +++++++------- odorobo/src/utils/mod.rs | 33 +++---- odoroboctl/src/cli.rs | 14 ++- 21 files changed, 203 insertions(+), 196 deletions(-) diff --git a/odorobo/src/actors/agent_actor.rs b/odorobo/src/actors/agent_actor.rs index 3745c2d..dcbc16a 100644 --- a/odorobo/src/actors/agent_actor.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -1,4 +1,16 @@ -use crate::{ch_driver::actor::VMActor, config::Config, messages::{Ping, Pong, agent::{AgentStatus, GetAgentStatus}, debug::PanicAgent, vm::*}, networking::actor::NetworkAgentActor, types::{ObjectMetadata, VirtualMachine}, utils::actor_names::{NETWORK, VM, vm_actor_id}}; +use crate::{ + ch_driver::actor::VMActor, + config::Config, + messages::{ + Ping, Pong, + agent::{AgentStatus, GetAgentStatus}, + debug::PanicAgent, + vm::*, + }, + networking::actor::NetworkAgentActor, + types::{ObjectMetadata, VirtualMachine}, + utils::actor_names::{NETWORK, VM, vm_actor_id}, +}; use ahash::AHashMap; use bytesize::ByteSize; use kameo::prelude::*; @@ -12,7 +24,7 @@ use kameo::error::PanicError; pub struct VMCacheData { actor_ref: ActorRef, - config: VirtualMachine + config: VirtualMachine, } #[derive(RemoteActor)] @@ -25,8 +37,6 @@ pub struct AgentActor { pub metadata: ObjectMetadata, } - - impl AgentActor { async fn lookup_vm_actor(vmid: Ulid) -> Option> { ActorRef::::lookup(format!("vm:{}", vmid)) @@ -107,8 +117,8 @@ impl Message for AgentActor { vmid, VMCacheData { actor_ref: actor_ref.clone(), - config: msg.config.clone() - } + config: msg.config.clone(), + }, ); info!(?vmid, "VM Spawned successfully"); @@ -136,8 +146,8 @@ impl Message for AgentActor { vmid, VMCacheData { actor_ref: actor_ref.clone(), - config: Default::default() - } + config: Default::default(), + }, ); // now ask the VM actor to handle the migration receive @@ -213,12 +223,15 @@ impl Message for AgentActor { ) -> Self::Reply { let Some(vmid) = msg.vmid else { warn!("No vmid provided for Agent Actor GetVMInfo forwarding"); - return ForwardedReply::from_ok(GetVMInfoReply { vmid: Ulid::nil(), config: None }) + return ForwardedReply::from_ok(GetVMInfoReply { + vmid: Ulid::nil(), + config: None, + }); }; let Some(actor_ref) = Self::lookup_vm_actor(vmid).await else { warn!(vm_id = %vmid, "VM actor not found for info lookup"); - return ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }) + return ForwardedReply::from_ok(GetVMInfoReply { vmid, config: None }); }; ctx.forward(&actor_ref, msg).await @@ -282,13 +295,16 @@ impl Message for AgentActor { _msg: GetAgentStatus, _ctx: &mut Context, ) -> Self::Reply { - - let vcpus_used_by_vms = self.vms.values() + let vcpus_used_by_vms = self + .vms + .values() .map(|vm| vm.config.data.vcpus) .reduce(|acc, cpus| acc + cpus) .unwrap_or(0) as u32; - let ram_used_by_vms = self.vms.values() + let ram_used_by_vms = self + .vms + .values() .map(|vm| vm.config.data.memory.as_u64()) .reduce(|acc, memory| acc + memory) .unwrap_or(0) as u64; @@ -299,7 +315,7 @@ impl Message for AgentActor { ram: self.memory, vms: self.vms.keys().copied().collect(), used_vcpus: vcpus_used_by_vms + self.config.reserved_vcpus, - used_ram: ByteSize::b(ram_used_by_vms) + used_ram: ByteSize::b(ram_used_by_vms), } } } diff --git a/odorobo/src/actors/http_actor.rs b/odorobo/src/actors/http_actor.rs index 53f9ec6..21e5a3c 100644 --- a/odorobo/src/actors/http_actor.rs +++ b/odorobo/src/actors/http_actor.rs @@ -1,8 +1,8 @@ -use kameo::prelude::*; use crate::messages::vm::{ - AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, - ShutdownVM, ShutdownVMReply, - }; + AgentListVMs, AgentListVMsReply, CreateVM, CreateVMReply, DeleteVM, DeleteVMReply, ShutdownVM, + ShutdownVMReply, +}; +use kameo::prelude::*; use stable_eyre::{Report, Result}; use super::scheduler_actor::SchedulerActor; diff --git a/odorobo/src/actors/scheduler_actor.rs b/odorobo/src/actors/scheduler_actor.rs index 11d6a62..445e69a 100644 --- a/odorobo/src/actors/scheduler_actor.rs +++ b/odorobo/src/actors/scheduler_actor.rs @@ -1,48 +1,50 @@ use std::ops::ControlFlow; -use async_trait::async_trait; -use kameo::prelude::*; -use libp2p::futures::TryStreamExt; use crate::actors::agent_actor::AgentActor; use crate::ch_driver::actor::VMActor; -use crate::utils::actor_cache::ActorCache; -use crate::utils::actor_cache::ActorCacheUpdater; -use crate::utils::actor_names::VM; -use crate::messages::vm::*; use crate::messages::agent::*; +use crate::messages::vm::*; use crate::messages::{Ping, Pong}; +use crate::utils::actor_cache::ActorCache; +use crate::utils::actor_cache::ActorCacheUpdater; use crate::utils::actor_names::AGENT; +use crate::utils::actor_names::VM; use crate::utils::actor_names::vm_actor_id; +use async_trait::async_trait; +use kameo::prelude::*; +use libp2p::futures::TryStreamExt; use stable_eyre::eyre::OptionExt; use stable_eyre::{Report, Result, eyre::eyre}; use tracing::info_span; use tracing::{info, warn}; - #[derive(RemoteActor)] pub struct SchedulerActor { pub agent_actor_cache: ActorCache, - pub vm_actor_cache: ActorCache + pub vm_actor_cache: ActorCache, } // todo: this might need to be a runtime thing but this makes it easy to write for now and could easily be switched out later. static VCPU_OVERPROVISIONMENT_NUMERATOR: u32 = 2; static VCPU_OVERPROVISIONMENT_DENOMINATOR: u32 = 1; - impl SchedulerActor { async fn lookup_by_actor_id( &mut self, actor_id: &ActorId, ) -> Option> { - self.agent_actor_cache.data_cache.get(actor_id).map(|data| data.actor_ref.clone()) + self.agent_actor_cache + .data_cache + .get(actor_id) + .map(|data| data.actor_ref.clone()) } - async fn lookup_by_hostname( - &mut self, - hostname: &str, - ) -> Option> { - self.agent_actor_cache.data_cache.iter().find(|data| data.metadata.hostname == hostname).map(|data| data.actor_ref.clone()) + async fn lookup_by_hostname(&mut self, hostname: &str) -> Option> { + self.agent_actor_cache + .data_cache + .iter() + .find(|data| data.metadata.hostname == hostname) + .map(|data| data.actor_ref.clone()) } /// current scheduling algo info: @@ -54,27 +56,26 @@ impl SchedulerActor { /// additionally, because caleb is way too performance brained, he used integer math for the entire scoring algorithm just so we didnt have to convert to floats. async fn schedule_agent( &mut self, - msg: &CreateVM + msg: &CreateVM, ) -> Result, Report> { let mut best_agent = None; let mut best_agent_score = 0u32; // todo: this arguably could be done as map-reduce. is that better? let span = info_span!("schedule_agent"); - span.in_scope(|| { + span.in_scope(|| { for agent in self.agent_actor_cache.data_cache.iter() { let mut agent_score = 0u32; - let agent_max_vcpus = agent.metadata.vcpus * VCPU_OVERPROVISIONMENT_NUMERATOR / VCPU_OVERPROVISIONMENT_DENOMINATOR; - - + let agent_max_vcpus = agent.metadata.vcpus * VCPU_OVERPROVISIONMENT_NUMERATOR + / VCPU_OVERPROVISIONMENT_DENOMINATOR; if agent.metadata.used_vcpus >= agent_max_vcpus { continue; } - agent_score += (agent_max_vcpus - agent.metadata.used_vcpus) * 1024 / agent_max_vcpus; - + agent_score += + (agent_max_vcpus - agent.metadata.used_vcpus) * 1024 / agent_max_vcpus; // todo: add ram overprovisionment. not adding this to scheduler until it works on the hypervisor side. let agent_max_ram = agent.metadata.ram; @@ -83,13 +84,11 @@ impl SchedulerActor { continue; } - agent_score += ((agent_max_ram.as_u64() - agent.metadata.used_ram.as_u64()) * 1024 / agent_max_ram.as_u64()) as u32; - + agent_score += ((agent_max_ram.as_u64() - agent.metadata.used_ram.as_u64()) * 1024 + / agent_max_ram.as_u64()) as u32; // todo: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ - - // todo (future): possibly keep a percent of agents completely empty, to be able to be converted to dedis automatically. // they would have their agent score set to 1, so they can be scheduled to if there is no other avaliable agents. // rough pseudo code to implement this: @@ -97,8 +96,6 @@ impl SchedulerActor { // agent_score = 1; // } - - info!(agent=?agent.value(), score=agent_score); if agent_score > best_agent_score { @@ -112,8 +109,6 @@ impl SchedulerActor { } } - - #[derive(Copy, Clone)] struct AgentActorCacheUpdater; @@ -136,7 +131,11 @@ impl ActorCacheUpdater for AgentActorCacheUpdater Ok(actor_ref_vec) } - async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result { + async fn on_update( + &self, + actor_ref: &RemoteActorRef, + previous_value: Option, + ) -> Result { let output_actor_ref = match previous_value { Some(value) => value.actor_ref, _ => actor_ref.clone(), @@ -144,12 +143,11 @@ impl ActorCacheUpdater for AgentActorCacheUpdater Ok(CachedAgentActor { actor_ref: output_actor_ref, - metadata: actor_ref.ask(&GetAgentStatus).await? + metadata: actor_ref.ask(&GetAgentStatus).await?, }) } } - // todo: this code is really bad, and we should not have effectively two copies of ths same thing. #[derive(Copy, Clone)] struct VMActorCacheUpdater; @@ -173,7 +171,11 @@ impl ActorCacheUpdater for VMActorCacheUpdater { Ok(actor_ref_vec) } - async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result { + async fn on_update( + &self, + actor_ref: &RemoteActorRef, + previous_value: Option, + ) -> Result { let output_actor_ref = match previous_value { Some(value) => value.actor_ref, _ => actor_ref.clone(), @@ -181,13 +183,11 @@ impl ActorCacheUpdater for VMActorCacheUpdater { Ok(CachedVMActor { actor_ref: output_actor_ref, - metadata: actor_ref.ask(&GetVMInfo {vmid: None}).await? + metadata: actor_ref.ask(&GetVMInfo { vmid: None }).await?, }) } } - - impl Actor for SchedulerActor { type Args = (); type Error = Report; @@ -199,11 +199,10 @@ impl Actor for SchedulerActor { Ok(Self { agent_actor_cache: ActorCache::new(actor_ref.clone(), AgentActorCacheUpdater)?, - vm_actor_cache: ActorCache::new(actor_ref, VMActorCacheUpdater)? + vm_actor_cache: ActorCache::new(actor_ref, VMActorCacheUpdater)?, }) } - async fn on_link_died( &mut self, actor_ref: WeakActorRef, @@ -225,24 +224,21 @@ impl Actor for SchedulerActor { } } - - - impl Message for SchedulerActor { type Reply = Result; - async fn handle(&mut self, msg: CreateVM, _ctx: &mut Context) -> Self::Reply { + async fn handle( + &mut self, + msg: CreateVM, + _ctx: &mut Context, + ) -> Self::Reply { loop { let target_agent = self.schedule_agent(&msg).await?; match target_agent.ask(&msg).await { - Ok(reply) => { - return Ok(reply) - }, + Ok(reply) => return Ok(reply), Err(err) => { - warn!( - "CreateVM forwarding failed, trying again: {err}" - ); + warn!("CreateVM forwarding failed, trying again: {err}"); } } } @@ -308,8 +304,6 @@ impl Message for SchedulerActor { } } - - impl Message for SchedulerActor { type Reply = Pong; diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 414b00b..48a91b5 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -1,10 +1,12 @@ -use crate::{ch_driver::VMInstance, types::VirtualMachine}; -use cloud_hypervisor_client::models::{CpusConfig, DiskConfig, ImageType, MemoryConfig, PayloadConfig, PlatformConfig, VmConfig}; -use kameo::prelude::*; use crate::messages::vm::{ DeleteVM, GetVMInfo, GetVMInfoReply, MigrateVMReceive, MigrateVMReceiveReply, PrepMigration, ShutdownVM, }; +use crate::{ch_driver::VMInstance, types::VirtualMachine}; +use cloud_hypervisor_client::models::{ + CpusConfig, DiskConfig, ImageType, MemoryConfig, PayloadConfig, PlatformConfig, VmConfig, +}; +use kameo::prelude::*; use serde::{Deserialize, Serialize}; use stable_eyre::{Report, Result}; use tokio::task::JoinHandle; @@ -37,7 +39,8 @@ impl Actor for VMActor { #[tracing::instrument(skip_all)] async fn on_start((vmid, vm_config): Self::Args, actor_ref: ActorRef) -> Result { - let mut vminstance = VMInstance::spawn(&vmid.to_string(), vm_config.map(VmConfig::from), None).await?; + let mut vminstance = + VMInstance::spawn(&vmid.to_string(), vm_config.map(VmConfig::from), None).await?; // Take the child process out so we can watch for unexpected death. // destroy() handles a missing child_process gracefully. @@ -117,13 +120,12 @@ impl From for VmConfig { firmware: Some("/var/lib/odorobo/CLOUDHV.fd".to_string()), ..Default::default() }, - disks: Some(vec![ - DiskConfig { // todo: get cappy to make this auto generate this via the manifest's volumes atribute. - path: Some(vm.data.image), - image_type: Some(ImageType::Raw), - ..Default::default() - } - ]), + disks: Some(vec![DiskConfig { + // todo: get cappy to make this auto generate this via the manifest's volumes atribute. + path: Some(vm.data.image), + image_type: Some(ImageType::Raw), + ..Default::default() + }]), // todo: generate from VM network field // net: Some(vec![ // NetConfig { diff --git a/odorobo/src/ch_driver/instance.rs b/odorobo/src/ch_driver/instance.rs index 8433f7e..0c95495 100644 --- a/odorobo/src/ch_driver/instance.rs +++ b/odorobo/src/ch_driver/instance.rs @@ -93,7 +93,6 @@ impl VMInstance { } } - /// Takes the child process out of this instance, transferring ownership to the caller. /// Useful for watching the process lifecycle externally (e.g. in an actor watcher task). /// After calling this, `destroy()` will skip the child-kill step. diff --git a/odorobo/src/ch_driver/mod.rs b/odorobo/src/ch_driver/mod.rs index 8701f5b..d555301 100644 --- a/odorobo/src/ch_driver/mod.rs +++ b/odorobo/src/ch_driver/mod.rs @@ -3,11 +3,11 @@ //! Runtime state (in /run) is not persisted across reboots, so we use it for //! ephemeral VM state like running instances. Persistent state goes in the database. +pub mod actor; pub mod api; pub mod devices; pub mod instance; pub mod provisioning; pub mod transform; -pub mod actor; -pub use instance::{VMInstance}; +pub use instance::VMInstance; diff --git a/odorobo/src/ch_driver/provisioning/hooks/mod.rs b/odorobo/src/ch_driver/provisioning/hooks/mod.rs index 9d6dda7..901e945 100644 --- a/odorobo/src/ch_driver/provisioning/hooks/mod.rs +++ b/odorobo/src/ch_driver/provisioning/hooks/mod.rs @@ -105,9 +105,7 @@ impl HookManager { impl Default for HookManager { fn default() -> Self { Self { - hooks: vec![ - Box::new(networking::NetworkProvisioningHook), - ], + hooks: vec![Box::new(networking::NetworkProvisioningHook)], } } } diff --git a/odorobo/src/ch_driver/transform/path_verify.rs b/odorobo/src/ch_driver/transform/path_verify.rs index ffe907b..c8e4a0d 100644 --- a/odorobo/src/ch_driver/transform/path_verify.rs +++ b/odorobo/src/ch_driver/transform/path_verify.rs @@ -19,17 +19,20 @@ impl ConfigTransform for PathVerify { let config = config.clone(); // payload path verification if let Some(kernel_path) = config.payload.kernel - && !Path::new(&kernel_path).is_absolute() { - return Err(eyre!("Kernel must be an absolute path")); - } + && !Path::new(&kernel_path).is_absolute() + { + return Err(eyre!("Kernel must be an absolute path")); + } if let Some(initramfs_path) = config.payload.initramfs - && !Path::new(&initramfs_path).is_absolute() { - return Err(eyre!("initramfs must be an absolute path")); - } + && !Path::new(&initramfs_path).is_absolute() + { + return Err(eyre!("initramfs must be an absolute path")); + } if let Some(firmware_path) = config.payload.firmware - && !Path::new(&firmware_path).is_absolute() { - return Err(eyre!("firmware must be an absolute path")); - } + && !Path::new(&firmware_path).is_absolute() + { + return Err(eyre!("firmware must be an absolute path")); + } // storage path verification if let Some(disk_configs) = config.disks { diff --git a/odorobo/src/config.rs b/odorobo/src/config.rs index 8b261ea..7f9ebb8 100644 --- a/odorobo/src/config.rs +++ b/odorobo/src/config.rs @@ -1,10 +1,10 @@ use ahash::AHashMap; +use clap::Parser; use ipnet::Ipv4Net; use serde::{Deserialize, Serialize}; -use std::{net::Ipv4Addr}; +use std::net::Ipv4Addr; use sysinfo::System; -use tracing::{ info, warn}; -use clap::Parser; +use tracing::{info, warn}; /// Gets the system hostname pub fn hostname() -> String { @@ -121,7 +121,6 @@ impl Default for NetworkMode { /// Additional runtime options for the agent that aren't applicable to a JSON config file #[derive(Parser)] pub struct CliConfig { - /// Whether the manager should be enabled on this instance #[clap(long, default_value = "false", env = "ODOROBO_MANAGER_ENABLED")] pub manager_enabled: bool, diff --git a/odorobo/src/http_api/mod.rs b/odorobo/src/http_api/mod.rs index 7b7ba40..4fa55d2 100644 --- a/odorobo/src/http_api/mod.rs +++ b/odorobo/src/http_api/mod.rs @@ -47,7 +47,9 @@ where } /// Serve the OpenAPI spec as JSON -async fn serve_api(Extension(api): Extension) -> Result { +async fn serve_api( + Extension(api): Extension, +) -> Result { Ok(Json(api)) } diff --git a/odorobo/src/http_api/nodes.rs b/odorobo/src/http_api/nodes.rs index eff0fa2..91bcbe9 100644 --- a/odorobo/src/http_api/nodes.rs +++ b/odorobo/src/http_api/nodes.rs @@ -16,7 +16,9 @@ pub fn router() -> ApiRouter> { .api_route("/{nodeid}", get(node_info)) } /// Drain a node of all VMs, migrating them away or shutting them down as needed. This is used for maintenance mode. -async fn drain(State(_state): State>) -> Result { +async fn drain( + State(_state): State>, +) -> Result { // stub Ok(Json("Draining...".to_string())) } diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index 6e6b2fb..a74d511 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -1,9 +1,10 @@ //! VM management API handlers. +use crate::messages::vm::{AgentListVMs, DeleteVM, ShutdownVM}; use crate::{ actors::http_actor::HTTPActor, - types::{ - CreateVMRequest, UpdateVMRequest, VirtualMachine, VMListResponse, VmId - }, messages::vm::CreateVM, utils::OdoroboError, + messages::vm::CreateVM, + types::{CreateVMRequest, UpdateVMRequest, VMListResponse, VirtualMachine, VmId}, + utils::OdoroboError, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -14,7 +15,6 @@ use axum::{ extract::{Path, State}, }; use kameo::actor::ActorRef; -use crate::messages::vm::{AgentListVMs, DeleteVM, ShutdownVM}; pub fn router() -> ApiRouter> { ApiRouter::new() @@ -26,7 +26,9 @@ pub fn router() -> ApiRouter> { .api_route("/{vmid}/shutdown", put(shutdown_vm)) } -async fn list_vms(State(state): State>) -> Result { +async fn list_vms( + State(state): State>, +) -> Result { let reply = state.ask(AgentListVMs).await?; Ok(Json(VMListResponse { diff --git a/odorobo/src/http_api/volumes.rs b/odorobo/src/http_api/volumes.rs index 79782a5..fa053c0 100644 --- a/odorobo/src/http_api/volumes.rs +++ b/odorobo/src/http_api/volumes.rs @@ -1,7 +1,8 @@ //! Volume management API handlers. use crate::{ actors::http_actor::HTTPActor, - types::{CreateVolumeRequest, VolumeId, VolumeInfo}, utils::OdoroboError, + types::{CreateVolumeRequest, VolumeId, VolumeInfo}, + utils::OdoroboError, }; use aide::axum::{ ApiRouter, IntoApiResponse, @@ -22,7 +23,9 @@ pub fn router() -> ApiRouter> { } /// Get detailed information about a specific volume -async fn volume_info(Path(VolumeId(_volid)): Path) -> Result { +async fn volume_info( + Path(VolumeId(_volid)): Path, +) -> Result { // stub, Ok(Json(VolumeInfo::default())) } diff --git a/odorobo/src/main.rs b/odorobo/src/main.rs index cd8dd39..ffb57c5 100644 --- a/odorobo/src/main.rs +++ b/odorobo/src/main.rs @@ -1,11 +1,11 @@ pub mod actors; -pub mod http_api; -pub mod networking; mod ch_driver; -mod utils; -pub mod messages; pub mod config; +pub mod http_api; +pub mod messages; +pub mod networking; pub mod types; +mod utils; use std::fs; @@ -13,12 +13,12 @@ use clap::Parser; use kameo::actor::Spawn; use stable_eyre::Result; +use crate::actors::agent_actor::AgentActor; use crate::actors::http_actor::HTTPActor; use crate::actors::scheduler_actor::SchedulerActor; use crate::config::Config; use crate::utils::actor_names::{HTTP_API_SERVER, SCHEDULER}; use crate::utils::{actor_names::AGENT, connect_to_swarm, init}; -use crate::actors::agent_actor::AgentActor; #[tokio::main] async fn main() -> Result<()> { @@ -34,11 +34,9 @@ async fn main() -> Result<()> { tracing::info!(?config, "Starting odorobo"); - let local_peer_id = connect_to_swarm().unwrap(); tracing::info!(?local_peer_id, "Peer ID"); - // start agents let agent_actor = AgentActor::spawn(config.clone()); agent_actor.register(AGENT).await?; @@ -54,7 +52,6 @@ async fn main() -> Result<()> { http_actor.wait_for_shutdown().await; } - agent_actor.wait_for_shutdown().await; Ok(()) diff --git a/odorobo/src/messages/agent.rs b/odorobo/src/messages/agent.rs index 581761e..eb1fd4d 100644 --- a/odorobo/src/messages/agent.rs +++ b/odorobo/src/messages/agent.rs @@ -3,7 +3,6 @@ use kameo::Reply; use serde::{Deserialize, Serialize}; use ulid::Ulid; - #[derive(Serialize, Deserialize)] pub struct GetAgentStatus; @@ -16,5 +15,5 @@ pub struct AgentStatus { pub ram: ByteSize, pub used_vcpus: u32, pub used_ram: ByteSize, - pub vms: Vec + pub vms: Vec, } diff --git a/odorobo/src/messages/mod.rs b/odorobo/src/messages/mod.rs index e9ce4c7..e7070b5 100644 --- a/odorobo/src/messages/mod.rs +++ b/odorobo/src/messages/mod.rs @@ -1,9 +1,9 @@ use kameo::prelude::*; use serde::{Deserialize, Serialize}; -pub mod vm; -pub mod debug; pub mod agent; +pub mod debug; +pub mod vm; /// A request to ping the server (keepalive) #[derive(Serialize, Deserialize, Debug)] diff --git a/odorobo/src/networking/actor.rs b/odorobo/src/networking/actor.rs index ea6d98b..bd8b644 100644 --- a/odorobo/src/networking/actor.rs +++ b/odorobo/src/networking/actor.rs @@ -391,7 +391,8 @@ impl Actor for NetworkAgentActor { let bridge = match bridge_lookup { Some(Ok(bridge)) => bridge, - Some(Err(NetlinkError::NetlinkError(ref nl_err))) if nl_err.raw_code() == -libc::ENODEV => + Some(Err(NetlinkError::NetlinkError(ref nl_err))) + if nl_err.raw_code() == -libc::ENODEV => { info!(bridge = %common.bridge, "bridge not found during lookup, creating it"); let new_bridge = LinkBridge::new(&common.bridge).up().build(); diff --git a/odorobo/src/types.rs b/odorobo/src/types.rs index b526cf8..1c177c0 100644 --- a/odorobo/src/types.rs +++ b/odorobo/src/types.rs @@ -71,7 +71,6 @@ pub struct CreateVMRequest { pub boot: bool, } - /// An internal, debug-only request for creating a VM. /// /// please don't use this in production, this is for debugging @@ -167,35 +166,34 @@ pub struct VirtualMachine { pub metadata: Option, /// List of Affinity rules for scheduling. These are ANDed / summed together depending on the strictness. - pub affinity: Option> + pub affinity: Option>, } - #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub struct AffinityRule { pub strictness: AffinityStrictness, pub affinity_type: AffinityType, pub direction: AffinityDirection, /// ORed together - pub requirements: Vec + pub requirements: Vec, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityStrictness { Required, - Preferred { weight: i64 } + Preferred { weight: i64 }, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityType { VirtualMachine, - Agent + Agent, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum AffinityDirection { Normal, - Anti + Anti, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] @@ -203,13 +201,13 @@ pub struct AffinityRequirement { pub key: String, pub table: MetadataTable, pub operator: Operator, - pub values: Vec + pub values: Vec, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] pub enum MetadataTable { Label, - Annotation + Annotation, } // todo: possibly replace with std::ops diff --git a/odorobo/src/utils/actor_cache.rs b/odorobo/src/utils/actor_cache.rs index eb4b45b..015e18e 100644 --- a/odorobo/src/utils/actor_cache.rs +++ b/odorobo/src/utils/actor_cache.rs @@ -2,9 +2,9 @@ use std::{marker::PhantomData, sync::Arc, time::Duration}; use async_trait::async_trait; use dashmap::DashMap; -use kameo::{prelude::*}; -use tokio::task::JoinHandle; +use kameo::prelude::*; use stable_eyre::{Report, Result}; +use tokio::task::JoinHandle; use tracing::{info, instrument, trace}; use std::fmt; @@ -23,33 +23,47 @@ use std::fmt; // - a default get_actor_refs that just finds all actor_refs with a specific actor string. // - change get_actor_refs to use an iterator - #[async_trait] -pub trait ActorCacheUpdater: Sync + Send + Copy + 'static { +pub trait ActorCacheUpdater< + ChildActor: Actor + RemoteActor, + Data: Clone + Send + Sync + 'static + fmt::Debug, +>: Sync + Send + Copy + 'static +{ // todo: this could probably be better if it was an iterator, but I am lazy and don't want to right now. async fn get_actor_refs(&self) -> Result>>; - async fn on_update(&self, actor_ref: &RemoteActorRef, previous_value: Option) -> Result; + async fn on_update( + &self, + actor_ref: &RemoteActorRef, + previous_value: Option, + ) -> Result; } - #[derive(Debug)] -pub struct ActorCache { +pub struct ActorCache< + ParentActor: Actor + RemoteActor, + ChildActor: Actor + RemoteActor, + Data: Clone + Send + Sync + 'static + fmt::Debug, +> { parent_actor_ref: ActorRef, pub data_cache: Arc>, keepalive_tasks: Arc>>, actor_finder: Option>, - child_actor_type: PhantomData + child_actor_type: PhantomData, } // todo: impl Drop to automatically kill all the keepalive_tasks and the actor_finder task. -impl ActorCache { +impl< + ParentActor: Actor + RemoteActor, + ChildActor: Actor + RemoteActor, + Data: Clone + Send + Sync + 'static + fmt::Debug, +> ActorCache +{ pub fn new( parent_actor_ref: ActorRef, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater, ) -> Result { - let data_cache = Arc::new(DashMap::new()); let keepalive_tasks = Arc::new(DashMap::new()); @@ -59,7 +73,7 @@ impl, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater, ) { let keepalive_tasks_clone = Arc::clone(&self.keepalive_tasks); let data_cache_clone = Arc::clone(&self.data_cache); @@ -97,8 +108,9 @@ impl, keepalive_tasks: Arc>>, data_cache: Arc>, - updater: impl ActorCacheUpdater + updater: impl ActorCacheUpdater, ) -> Result<(), Report> { let actor_refs = updater.get_actor_refs().await?; @@ -124,17 +136,10 @@ impl, - data_cache: Arc>, - updater: impl ActorCacheUpdater + data_cache: Arc>, + updater: impl ActorCacheUpdater, ) { let mut interval = tokio::time::interval(Duration::from_secs(1)); @@ -154,18 +159,12 @@ impl From> for OdoroboError { fn from(value: kameo::error::SendError) -> Self { let kameo_error = value.to_string(); error!(?value); - OdoroboError::Report(value.err().unwrap_or_else(|| { - Report::msg(format!("could not unwrap kameo error: {kameo_error}")) - })) + OdoroboError::Report( + value.err().unwrap_or_else(|| { + Report::msg(format!("could not unwrap kameo error: {kameo_error}")) + }), + ) } } #[cfg(test)] mod tests { use super::*; - use axum::{ - http::StatusCode, - body::Body, http::Request, - routing::get, - Router, - }; + use axum::{Router, body::Body, http::Request, http::StatusCode, routing::get}; use http_body_util::BodyExt; use tower::ServiceExt; @@ -50,7 +47,8 @@ mod tests { #[tokio::test] async fn test_error() { - let response = Router::new().route("/", get(handler)) + let response = Router::new() + .route("/", get(handler)) .oneshot(Request::get("/").body(Body::empty()).unwrap()) .await .unwrap(); @@ -64,7 +62,6 @@ mod tests { } } - pub fn env_filter(debug_target: Option<&str>) -> EnvFilter { let env = std::env::var("ODOROBO_LOG").unwrap_or_else(|_| "".into()); @@ -110,8 +107,6 @@ pub fn init_default() -> Result<()> { init(None) } - - #[derive(NetworkBehaviour)] pub struct ProductionBehaviour { kameo: remote::Behaviour, diff --git a/odoroboctl/src/cli.rs b/odoroboctl/src/cli.rs index b769271..ea7e031 100644 --- a/odoroboctl/src/cli.rs +++ b/odoroboctl/src/cli.rs @@ -1,10 +1,10 @@ +use bytesize::ByteSize; use clap::{Parser, Subcommand}; +use odorobo::types::{CreateVMRequest, VMData, VirtualMachine}; use reqwest::{Client, Response}; -use serde::{Deserialize}; +use serde::Deserialize; use stable_eyre::Result; -use odorobo::types::{CreateVMRequest, VMData, VirtualMachine}; use ulid::Ulid; -use bytesize::ByteSize; #[derive(Parser)] #[command( @@ -96,7 +96,8 @@ pub async fn run_command(cli: Cli) -> Result<()> { let base_url = cli.manager_addr; match cli.command { - Command::Create => { // TODO: setup actual cli args for these parameters. or just take in arbitrary json and serialize it into a VirtualMachine. + Command::Create => { + // TODO: setup actual cli args for these parameters. or just take in arbitrary json and serialize it into a VirtualMachine. let vm = VirtualMachine { data: VMData { id: Ulid::new(), @@ -110,10 +111,7 @@ pub async fn run_command(cli: Cli) -> Result<()> { ..Default::default() }; - let request = CreateVMRequest { - vm, - boot: true - }; + let request = CreateVMRequest { vm, boot: true }; let url = format!("{}/vms", base_url); let response = client.post(&url).json(&request).send().await?; From 7787446821439dc22e700bc3cea9a13e45188805 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 17:24:15 -0600 Subject: [PATCH 183/189] fix clippy's complaints --- odorobo/Cargo.toml | 6 +- odorobo/src/actors/agent_actor.rs | 4 +- odorobo/src/actors/scheduler_actor.rs | 10 +- odorobo/src/ch_driver/actor.rs | 2 +- odorobo/src/ch_driver/instance.rs | 8 +- .../src/ch_driver/provisioning/hooks/mod.rs | 12 + odorobo/src/http_api/vms.rs | 2 +- odorobo/src/networking/actor.rs | 707 +----------------- odorobo/src/networking/actor_linux.rs | 704 +++++++++++++++++ odorobo/src/networking/actor_unsupported.rs | 57 ++ odorobo/src/networking/mod.rs | 6 + odorobo/src/utils/actor_cache.rs | 12 +- odorobo/src/utils/mod.rs | 4 + 13 files changed, 821 insertions(+), 713 deletions(-) create mode 100644 odorobo/src/networking/actor_linux.rs create mode 100644 odorobo/src/networking/actor_unsupported.rs diff --git a/odorobo/Cargo.toml b/odorobo/Cargo.toml index 70a6407..3566ebc 100644 --- a/odorobo/Cargo.toml +++ b/odorobo/Cargo.toml @@ -33,8 +33,6 @@ random-port = "0.1" if-addrs = "0.15" libp2p = { version = "0.56", features = ["yamux", "serde", "mdns"] } sysinfo = "0.38" -rtnetlink = "0.21" -nftables = { version = "0.6", features = ["tokio"] } rand = "0.10" axum = { version = "0.8", features = ["ws"] } axum-extra = "0.12" @@ -65,3 +63,7 @@ schemars = "0.9" cloud-hypervisor-client = { git = "https://github.com/FyraStack/cloud-hypervisor-client.git" } + +[target.'cfg(target_os = "linux")'.dependencies] +rtnetlink = "0.21" +nftables = { version = "0.6", features = ["tokio"] } diff --git a/odorobo/src/actors/agent_actor.rs b/odorobo/src/actors/agent_actor.rs index dcbc16a..4f54cbe 100644 --- a/odorobo/src/actors/agent_actor.rs +++ b/odorobo/src/actors/agent_actor.rs @@ -300,14 +300,14 @@ impl Message for AgentActor { .values() .map(|vm| vm.config.data.vcpus) .reduce(|acc, cpus| acc + cpus) - .unwrap_or(0) as u32; + .unwrap_or(0); let ram_used_by_vms = self .vms .values() .map(|vm| vm.config.data.memory.as_u64()) .reduce(|acc, memory| acc + memory) - .unwrap_or(0) as u64; + .unwrap_or(0); AgentStatus { hostname: self.config.hostname.clone(), diff --git a/odorobo/src/actors/scheduler_actor.rs b/odorobo/src/actors/scheduler_actor.rs index 445e69a..dda999e 100644 --- a/odorobo/src/actors/scheduler_actor.rs +++ b/odorobo/src/actors/scheduler_actor.rs @@ -29,6 +29,10 @@ static VCPU_OVERPROVISIONMENT_NUMERATOR: u32 = 2; static VCPU_OVERPROVISIONMENT_DENOMINATOR: u32 = 1; impl SchedulerActor { + #[expect( + dead_code, + reason = "scheduler lookup helper reserved for explicit placement by actor id" + )] async fn lookup_by_actor_id( &mut self, actor_id: &ActorId, @@ -39,6 +43,10 @@ impl SchedulerActor { .map(|data| data.actor_ref.clone()) } + #[expect( + dead_code, + reason = "scheduler lookup helper reserved for explicit placement by hostname" + )] async fn lookup_by_hostname(&mut self, hostname: &str) -> Option> { self.agent_actor_cache .data_cache @@ -56,7 +64,7 @@ impl SchedulerActor { /// additionally, because caleb is way too performance brained, he used integer math for the entire scoring algorithm just so we didnt have to convert to floats. async fn schedule_agent( &mut self, - msg: &CreateVM, + _msg: &CreateVM, ) -> Result, Report> { let mut best_agent = None; let mut best_agent_score = 0u32; diff --git a/odorobo/src/ch_driver/actor.rs b/odorobo/src/ch_driver/actor.rs index 48a91b5..c5f3654 100644 --- a/odorobo/src/ch_driver/actor.rs +++ b/odorobo/src/ch_driver/actor.rs @@ -52,7 +52,7 @@ impl Actor for VMActor { Ok(status) => { if !status.success() { error!(%vmid, ?status, "child process exited unexpectedly, killing actor"); - let _ = actor_ref.kill(); + actor_ref.kill(); } else { warn!(%vmid, "child process exited outside of actor teardown"); let _ = actor_ref.stop_gracefully().await; diff --git a/odorobo/src/ch_driver/instance.rs b/odorobo/src/ch_driver/instance.rs index 0c95495..c438931 100644 --- a/odorobo/src/ch_driver/instance.rs +++ b/odorobo/src/ch_driver/instance.rs @@ -105,7 +105,13 @@ impl VMInstance { /// Not reliable as of 0.2 #[deprecated(since = "0.2.0")] pub fn get(vmid: &str) -> Option { - Self::list().ok()?.into_iter().find(|i| i.id == vmid) + #[expect( + deprecated, + reason = "deprecated filesystem getter is implemented in terms of the deprecated filesystem listing" + )] + let instances = Self::list().ok()?; + + instances.into_iter().find(|i| i.id == vmid) } pub async fn boot(&self) -> Result<()> { diff --git a/odorobo/src/ch_driver/provisioning/hooks/mod.rs b/odorobo/src/ch_driver/provisioning/hooks/mod.rs index 901e945..43560b3 100644 --- a/odorobo/src/ch_driver/provisioning/hooks/mod.rs +++ b/odorobo/src/ch_driver/provisioning/hooks/mod.rs @@ -42,11 +42,19 @@ pub struct HookManager { } impl HookManager { + #[expect( + dead_code, + reason = "hook registration API is used as provisioning integrations are added" + )] pub fn add_hook(mut self, hook: T) -> Self { self.hooks.push(Box::new(hook)); self } + #[expect( + dead_code, + reason = "provisioning lifecycle method reserved for start-time integrations" + )] pub async fn before_start(&self, vmid: &str, config: &VmConfig) -> Result<()> { for hook in &self.hooks { hook.before_start(vmid, config).await?; @@ -54,6 +62,10 @@ impl HookManager { Ok(()) } + #[expect( + dead_code, + reason = "provisioning lifecycle method reserved for start-time integrations" + )] pub async fn after_start(&self, vmid: &str, config: &VmInfo, pid: i32) -> Result<()> { for hook in &self.hooks { hook.after_start(vmid, config, pid).await?; diff --git a/odorobo/src/http_api/vms.rs b/odorobo/src/http_api/vms.rs index a74d511..329244d 100644 --- a/odorobo/src/http_api/vms.rs +++ b/odorobo/src/http_api/vms.rs @@ -63,7 +63,7 @@ async fn delete_vm( State(state): State>, Path(VmId(vmid)): Path, ) -> Result { - let reply = state.ask(DeleteVM { vmid }).await?; + let _reply = state.ask(DeleteVM { vmid }).await?; Ok(Json(())) } diff --git a/odorobo/src/networking/actor.rs b/odorobo/src/networking/actor.rs index bd8b644..8c8bd7f 100644 --- a/odorobo/src/networking/actor.rs +++ b/odorobo/src/networking/actor.rs @@ -1,704 +1,5 @@ -use crate::config::{DhcpConfig, NetworkConfig, NetworkMode}; -use crate::networking::messages::{AttachTap, DetachTap}; -use futures_util::{StreamExt, TryStreamExt}; +#[cfg(target_os = "linux")] +pub use super::actor_linux::*; -use kameo::{message::Context, prelude::*}; -use nftables::{ - batch::Batch as NftBatch, - expr::{Expression, Meta, MetaKey, NamedExpression}, - helper::{apply_ruleset, get_current_ruleset}, - schema::{Chain as NftChain, NfListObject, NfObject, Rule as NftRule, Table as NftTable}, - stmt::{Match as NftMatch, Operator, Statement}, - types::{NfChainPolicy, NfChainType, NfFamily, NfHook}, -}; -use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; -use stable_eyre::Report; -use stable_eyre::eyre::{Context as EyreContext, eyre}; -use tracing::info; - -pub struct DhcpActor { - pub config: DhcpConfig, - bridge: String, - dnsmasq_process: Option, -} - -pub struct NetworkConfigCommon { - pub bridge: String, - pub subnet: String, - pub upstream_iface: Option, -} - -impl Actor for DhcpActor { - type Args = (DhcpConfig, String); - type Error = Report; - async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { - let (config, bridge) = args; - let dhcp_range = format!( - "{},{},{},{}", - config.range.0, - config.range.1, - config.subnet.netmask(), - config.lease_time - ); - - info!( - bridge = %bridge, - range_start = %config.range.0, - range_end = %config.range.1, - subnet = %config.subnet, - lease_time = %config.lease_time, - "starting dnsmasq for guest DHCP" - ); - - let dnsmasq_process = tokio::process::Command::new("dnsmasq") - .arg("--interface") - .arg(&bridge) - .arg("--bind-interfaces") - .arg("--dhcp-range") - .arg(&dhcp_range) - .arg("--no-daemon") - .spawn() - .wrap_err_with(|| format!("failed to start dnsmasq on bridge {bridge}"))?; - - Ok(Self { - config, - bridge, - dnsmasq_process: Some(dnsmasq_process), - }) - } - - async fn on_stop( - &mut self, - _actor_ref: WeakActorRef, - _reason: ActorStopReason, - ) -> std::result::Result<(), Self::Error> { - if let Some(mut dnsmasq_process) = self.dnsmasq_process.take() { - dnsmasq_process - .start_kill() - .wrap_err_with(|| format!("failed to stop dnsmasq on bridge {}", self.bridge))?; - - let _ = dnsmasq_process.wait().await; - } - - Ok(()) - } -} - -async fn ensure_address( - handle: &Handle, - link_index: u32, - link_name: &str, - address: std::net::Ipv4Addr, - prefix_len: u8, -) -> Result<(), Report> { - let mut existing = handle - .address() - .get() - .set_link_index_filter(link_index) - .execute(); - - while let Some(address_msg) = existing.try_next().await? { - if address_msg.header.prefix_len != prefix_len { - continue; - } - - let already_present = address_msg.attributes.iter().any(|attr| match attr { - rtnetlink::packet_route::address::AddressAttribute::Address(ip) - | rtnetlink::packet_route::address::AddressAttribute::Local(ip) => { - matches!(ip, std::net::IpAddr::V4(existing_ip) if *existing_ip == address) - } - _ => false, - }); - - if already_present { - info!( - bridge = link_name, - address = %address, - prefix_len, - "bridge gateway address already present" - ); - return Ok(()); - } - } - - info!( - bridge = link_name, - address = %address, - prefix_len, - "adding gateway address to bridge" - ); - - match handle - .address() - .add(link_index, address.into(), prefix_len) - .execute() - .await - { - Ok(()) => Ok(()), - Err(NetlinkError::NetlinkError(err)) if err.raw_code() == -libc::EEXIST => Ok(()), - Err(err) => Err(err).wrap_err_with(|| { - format!("failed to add address {address}/{prefix_len} to {link_name}") - }), - } -} - -#[derive(RemoteActor)] -pub struct NetworkAgentActor { - pub config: NetworkConfig, - common: NetworkConfigCommon, - pub dhcp_actor: Option>, - // netlink_handle: - netlink_thread: tokio::task::JoinHandle<()>, - netlink_handle: Handle, -} - -impl NetworkAgentActor { - async fn lookup_link_by_name( - &self, - link_name: &str, - ) -> Result { - self.netlink_handle - .link() - .get() - .match_name(link_name.to_string()) - .execute() - .next() - .await - .ok_or_else(|| eyre!("link {} not found", link_name))? - .wrap_err_with(|| format!("failed to query link {}", link_name)) - } - - fn nft_table_exists(objects: &[NfObject<'_>], table: &str) -> bool { - objects.iter().any(|object| match object { - NfObject::ListObject(NfListObject::Table(existing)) => { - existing.family == NfFamily::IP && existing.name.as_ref() == table - } - _ => false, - }) - } - - fn nft_chain_exists(objects: &[NfObject<'_>], table: &str, chain: &str) -> bool { - objects.iter().any(|object| match object { - NfObject::ListObject(NfListObject::Chain(existing)) => { - existing.family == NfFamily::IP - && existing.table.as_ref() == table - && existing.name.as_ref() == chain - } - _ => false, - }) - } - - fn nft_postrouting_masquerade_exists( - objects: &[NfObject<'_>], - table: &str, - chain: &str, - upstream_iface: &str, - ) -> bool { - objects.iter().any(|object| match object { - NfObject::ListObject(NfListObject::Rule(existing)) => { - existing.family == NfFamily::IP - && existing.table.as_ref() == table - && existing.chain.as_ref() == chain - && matches!( - existing.expr.as_ref(), - [Statement::Match(NftMatch { left, right, op }), Statement::Masquerade(_)] - if *op == Operator::EQ - && matches!( - left, - Expression::Named(NamedExpression::Meta(Meta { key: MetaKey::Oifname })) - ) - && matches!(right, Expression::String(iface) if iface.as_ref() == upstream_iface) - ) - } - _ => false, - }) - } - - // todo: IPv6, refer to libvirt's impl: - // ```nft - // table ip6 libvirt_network { - // chain forward { - // type filter hook forward priority filter; policy accept; - // counter packets 0 bytes 0 jump guest_cross - // counter packets 0 bytes 0 jump guest_input - // counter packets 0 bytes 0 jump guest_output - // } - - // chain guest_output { - // } - - // chain guest_input { - // } - - // chain guest_cross { - // } - - // chain guest_nat { - // type nat hook postrouting priority srcnat; policy accept; - // } - // } - // ``` - /// Ensures the host-only NAT masquerade rule exists for the configured - /// upstream interface. - /// - /// This is intentionally IPv4-only for now. - /// - /// The current host-only networking model is built around IPv4 guest - /// addressing and an IPv4 bridge gateway: - /// - guest subnet config uses `Ipv4Net` - /// - bridge gateway config uses `Ipv4Addr` - /// - bridge address assignment is currently IPv4-only - /// - /// Although nftables can match by interface in `inet` tables, switching - /// this masquerade rule to dual-stack would implicitly opt us into IPv6 - /// NAT/NAT66 policy as well. That is a larger design decision than this - /// hook should make on its own. Until odorobo has an intentional IPv6 - /// guest-networking story, we keep host-only NAT scoped to IPv4. - fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { - const TABLE_NAME: &str = "odorobo"; - const CHAIN_NAME: &str = "guest_nat"; - - info!( - table = TABLE_NAME, - chain = CHAIN_NAME, - upstream_iface = upstream_iface, - "ensuring host-only NAT masquerade rule exists" - ); - - let ruleset = get_current_ruleset().wrap_err("failed to fetch current nftables ruleset")?; - let objects = ruleset.objects.as_ref(); - - let table_exists = Self::nft_table_exists(objects, TABLE_NAME); - let chain_exists = Self::nft_chain_exists(objects, TABLE_NAME, CHAIN_NAME); - let rule_exists = Self::nft_postrouting_masquerade_exists( - objects, - TABLE_NAME, - CHAIN_NAME, - upstream_iface, - ); - - if rule_exists { - info!( - table = TABLE_NAME, - chain = CHAIN_NAME, - upstream_iface = upstream_iface, - "existing NAT masquerade rule already present" - ); - return Ok(()); - } - - let mut batch = NftBatch::new(); - - if !table_exists { - info!(table = TABLE_NAME, "creating odorobo nftables table"); - batch.add(NfListObject::Table(NftTable { - family: NfFamily::IP, - name: TABLE_NAME.into(), - handle: None, - })); - } - - if !chain_exists { - info!( - table = TABLE_NAME, - chain = CHAIN_NAME, - "creating odorobo nftables chain" - ); - batch.add(NfListObject::Chain(NftChain { - family: NfFamily::IP, - table: TABLE_NAME.into(), - name: CHAIN_NAME.into(), - newname: None, - handle: None, - _type: Some(NfChainType::NAT), - hook: Some(NfHook::Postrouting), - prio: Some(100), - dev: None, - policy: Some(NfChainPolicy::Accept), - })); - } - - batch.add(NfListObject::Rule(NftRule { - family: NfFamily::IP, - table: TABLE_NAME.into(), - chain: CHAIN_NAME.into(), - expr: vec![ - Statement::Match(NftMatch { - left: Expression::Named(NamedExpression::Meta(Meta { - key: MetaKey::Oifname, - })), - right: Expression::String(upstream_iface.into()), - op: Operator::EQ, - }), - Statement::Masquerade(None), - ] - .into(), - handle: None, - index: None, - comment: Some("odorobo host-only NAT".into()), - })); - - let batch = batch.to_nftables(); - apply_ruleset(&batch).wrap_err_with(|| { - format!( - "failed to apply nftables postrouting masquerade for upstream interface {upstream_iface}" - ) - })?; - - Ok(()) - } -} - -impl Actor for NetworkAgentActor { - type Args = NetworkConfig; - type Error = Report; - async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { - // do some netlink fuckery here - - let (connection, handle, _) = rtnetlink::new_connection()?; - let netlink_thread = tokio::spawn(connection); - - let common = match args.network_mode.clone() { - NetworkMode::HostonlyNat { - bridge, - subnet, - gateway: _, - upstream_iface, - } => NetworkConfigCommon { - bridge, - subnet: subnet.to_string(), - upstream_iface: Some(upstream_iface), - }, - NetworkMode::Bridged { - bridge, - subnet, - gateway: _, - } => NetworkConfigCommon { - bridge, - subnet: subnet.to_string(), - upstream_iface: None, - }, - }; - - // ensure the bridge exists, creating it if necessary - let bridge_lookup = handle - .link() - .get() - .match_name(common.bridge.clone()) - .execute() - .next() - .await; - - let bridge = match bridge_lookup { - Some(Ok(bridge)) => bridge, - Some(Err(NetlinkError::NetlinkError(ref nl_err))) - if nl_err.raw_code() == -libc::ENODEV => - { - info!(bridge = %common.bridge, "bridge not found during lookup, creating it"); - let new_bridge = LinkBridge::new(&common.bridge).up().build(); - handle - .link() - .add(new_bridge) - .execute() - .await - .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; - - handle - .link() - .get() - .match_name(common.bridge.clone()) - .execute() - .next() - .await - .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? - .wrap_err_with(|| { - format!("failed to query bridge {} after creation", common.bridge) - })? - } - Some(Err(err)) => { - return Err(err) - .wrap_err_with(|| format!("failed to query bridge {}", common.bridge)); - } - None => { - info!(bridge = %common.bridge, "creating new bridge"); - let new_bridge = LinkBridge::new(&common.bridge).up().build(); - handle - .link() - .add(new_bridge) - .execute() - .await - .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; - - handle - .link() - .get() - .match_name(common.bridge.clone()) - .execute() - .next() - .await - .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? - .wrap_err_with(|| { - format!("failed to query bridge {} after creation", common.bridge) - })? - } - }; - - let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { - Some( - DhcpActor::spawn_link(&actor_ref, (dhcp_config.clone(), common.bridge.clone())) - .await, - ) - } else { - None - }; - - let actor = Self { - config: args, - common, - dhcp_actor, - netlink_thread, - netlink_handle: handle, - }; - - let common_bridge = actor.common.bridge.clone(); - let common_subnet = actor.common.subnet.clone(); - - info!( - bridge = %common_bridge, - subnet = %common_subnet, - upstream_iface = ?actor.common.upstream_iface, - "network actor startup configuration resolved" - ); - - match actor.config.network_mode.clone() { - NetworkMode::HostonlyNat { - bridge: _, - subnet, - gateway, - upstream_iface: _, - } => { - info!( - bridge = %actor.common.bridge, - gateway = %gateway, - prefix_len = subnet.prefix_len(), - "ensuring host-only NAT bridge gateway address" - ); - - ensure_address( - &actor.netlink_handle, - bridge.header.index, - &actor.common.bridge, - gateway, - subnet.prefix_len(), - ) - .await - .wrap_err_with(|| { - format!( - "failed to ensure gateway {}/{} exists on bridge {}", - gateway, - subnet.prefix_len(), - actor.common.bridge - ) - })?; - - let upstream_iface = - actor.common.upstream_iface.as_deref().ok_or_else(|| { - eyre!("host-only NAT mode requires an upstream interface") - })?; - - info!( - bridge = %common_bridge, - upstream_iface = upstream_iface, - "host-only NAT uses routed upstream interface rather than bridge master" - ); - - Self::ensure_nat_rules(&common_bridge, &common_subnet, upstream_iface) - .wrap_err_with(|| { - format!( - "failed to ensure nftables NAT rules for bridge {} and upstream {}", - common_bridge, upstream_iface - ) - })?; - } - NetworkMode::Bridged { - bridge: _, - subnet, - gateway, - } => { - info!( - bridge = %actor.common.bridge, - gateway = %gateway, - prefix_len = subnet.prefix_len(), - "ensuring bridged mode host address on bridge" - ); - - ensure_address( - &actor.netlink_handle, - bridge.header.index, - &actor.common.bridge, - gateway, - subnet.prefix_len(), - ) - .await - .wrap_err_with(|| { - format!( - "failed to ensure gateway {}/{} exists on bridge {}", - gateway, - subnet.prefix_len(), - actor.common.bridge - ) - })?; - } - } - - Ok(actor) - } - - async fn on_stop( - &mut self, - _actor_ref: WeakActorRef, - reason: ActorStopReason, - ) -> std::result::Result<(), Self::Error> { - match reason { - ActorStopReason::Normal => { - info!(bridge = %self.common.bridge, "stopping network agent"); - } - ActorStopReason::Killed => { - info!(bridge = %self.common.bridge, "network agent killed"); - } - ActorStopReason::Panicked(err) => { - info!(bridge = %self.common.bridge, ?err, "network agent panicked"); - } - _ => { - info!(bridge = %self.common.bridge, "network agent stopping"); - } - } - - if let Some(dhcp_actor) = self.dhcp_actor.take() { - dhcp_actor.stop_gracefully().await?; - } - - self.netlink_thread.abort(); - let _ = (&mut self.netlink_thread).await; - - Ok(()) - } -} - -impl Message for NetworkAgentActor { - type Reply = Result<(), Report>; - - async fn handle( - &mut self, - msg: AttachTap, - _ctx: &mut Context, - ) -> Self::Reply { - let bridge = self - .lookup_link_by_name(&self.common.bridge) - .await - .wrap_err_with(|| { - format!( - "failed to resolve bridge {} before attaching tap {}", - self.common.bridge, msg.tap_name - ) - })?; - - let tap = self - .lookup_link_by_name(&msg.tap_name) - .await - .wrap_err_with(|| { - format!("failed to resolve tap {} for vm {}", msg.tap_name, msg.vmid) - })?; - - info!( - vmid = %msg.vmid, - tap = %msg.tap_name, - bridge = %self.common.bridge, - "attaching tap to bridge" - ); - - self.netlink_handle - .link() - .set( - LinkUnspec::new_with_index(tap.header.index) - .controller(bridge.header.index) - .build(), - ) - .execute() - .await - .wrap_err_with(|| { - format!( - "failed to attach tap {} to bridge {}", - msg.tap_name, self.common.bridge - ) - })?; - - self.netlink_handle - .link() - .set(LinkUnspec::new_with_index(tap.header.index).up().build()) - .execute() - .await - .wrap_err_with(|| format!("failed to bring tap {} up", msg.tap_name))?; - - info!( - vmid = %msg.vmid, - tap = %msg.tap_name, - bridge = %self.common.bridge, - "tap attached to bridge successfully" - ); - - Ok(()) - } -} - -impl Message for NetworkAgentActor { - type Reply = Result<(), Report>; - - async fn handle( - &mut self, - msg: DetachTap, - _ctx: &mut Context, - ) -> Self::Reply { - let tap = self - .lookup_link_by_name(&msg.tap_name) - .await - .wrap_err_with(|| { - format!( - "failed to resolve tap {} for detach on vm {}", - msg.tap_name, msg.vmid - ) - })?; - - info!( - vmid = %msg.vmid, - tap = %msg.tap_name, - bridge = %self.common.bridge, - "detaching tap from bridge" - ); - - self.netlink_handle - .link() - .set( - LinkUnspec::new_with_index(tap.header.index) - .nocontroller() - .build(), - ) - .execute() - .await - .wrap_err_with(|| { - format!( - "failed to detach tap {} from bridge {}", - msg.tap_name, self.common.bridge - ) - })?; - - info!( - vmid = %msg.vmid, - tap = %msg.tap_name, - bridge = %self.common.bridge, - "tap detached from bridge successfully" - ); - - Ok(()) - } -} +#[cfg(not(target_os = "linux"))] +pub use super::actor_unsupported::*; diff --git a/odorobo/src/networking/actor_linux.rs b/odorobo/src/networking/actor_linux.rs new file mode 100644 index 0000000..bd8b644 --- /dev/null +++ b/odorobo/src/networking/actor_linux.rs @@ -0,0 +1,704 @@ +use crate::config::{DhcpConfig, NetworkConfig, NetworkMode}; +use crate::networking::messages::{AttachTap, DetachTap}; +use futures_util::{StreamExt, TryStreamExt}; + +use kameo::{message::Context, prelude::*}; +use nftables::{ + batch::Batch as NftBatch, + expr::{Expression, Meta, MetaKey, NamedExpression}, + helper::{apply_ruleset, get_current_ruleset}, + schema::{Chain as NftChain, NfListObject, NfObject, Rule as NftRule, Table as NftTable}, + stmt::{Match as NftMatch, Operator, Statement}, + types::{NfChainPolicy, NfChainType, NfFamily, NfHook}, +}; +use rtnetlink::{Error as NetlinkError, Handle, LinkBridge, LinkUnspec}; +use stable_eyre::Report; +use stable_eyre::eyre::{Context as EyreContext, eyre}; +use tracing::info; + +pub struct DhcpActor { + pub config: DhcpConfig, + bridge: String, + dnsmasq_process: Option, +} + +pub struct NetworkConfigCommon { + pub bridge: String, + pub subnet: String, + pub upstream_iface: Option, +} + +impl Actor for DhcpActor { + type Args = (DhcpConfig, String); + type Error = Report; + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + let (config, bridge) = args; + let dhcp_range = format!( + "{},{},{},{}", + config.range.0, + config.range.1, + config.subnet.netmask(), + config.lease_time + ); + + info!( + bridge = %bridge, + range_start = %config.range.0, + range_end = %config.range.1, + subnet = %config.subnet, + lease_time = %config.lease_time, + "starting dnsmasq for guest DHCP" + ); + + let dnsmasq_process = tokio::process::Command::new("dnsmasq") + .arg("--interface") + .arg(&bridge) + .arg("--bind-interfaces") + .arg("--dhcp-range") + .arg(&dhcp_range) + .arg("--no-daemon") + .spawn() + .wrap_err_with(|| format!("failed to start dnsmasq on bridge {bridge}"))?; + + Ok(Self { + config, + bridge, + dnsmasq_process: Some(dnsmasq_process), + }) + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + _reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + if let Some(mut dnsmasq_process) = self.dnsmasq_process.take() { + dnsmasq_process + .start_kill() + .wrap_err_with(|| format!("failed to stop dnsmasq on bridge {}", self.bridge))?; + + let _ = dnsmasq_process.wait().await; + } + + Ok(()) + } +} + +async fn ensure_address( + handle: &Handle, + link_index: u32, + link_name: &str, + address: std::net::Ipv4Addr, + prefix_len: u8, +) -> Result<(), Report> { + let mut existing = handle + .address() + .get() + .set_link_index_filter(link_index) + .execute(); + + while let Some(address_msg) = existing.try_next().await? { + if address_msg.header.prefix_len != prefix_len { + continue; + } + + let already_present = address_msg.attributes.iter().any(|attr| match attr { + rtnetlink::packet_route::address::AddressAttribute::Address(ip) + | rtnetlink::packet_route::address::AddressAttribute::Local(ip) => { + matches!(ip, std::net::IpAddr::V4(existing_ip) if *existing_ip == address) + } + _ => false, + }); + + if already_present { + info!( + bridge = link_name, + address = %address, + prefix_len, + "bridge gateway address already present" + ); + return Ok(()); + } + } + + info!( + bridge = link_name, + address = %address, + prefix_len, + "adding gateway address to bridge" + ); + + match handle + .address() + .add(link_index, address.into(), prefix_len) + .execute() + .await + { + Ok(()) => Ok(()), + Err(NetlinkError::NetlinkError(err)) if err.raw_code() == -libc::EEXIST => Ok(()), + Err(err) => Err(err).wrap_err_with(|| { + format!("failed to add address {address}/{prefix_len} to {link_name}") + }), + } +} + +#[derive(RemoteActor)] +pub struct NetworkAgentActor { + pub config: NetworkConfig, + common: NetworkConfigCommon, + pub dhcp_actor: Option>, + // netlink_handle: + netlink_thread: tokio::task::JoinHandle<()>, + netlink_handle: Handle, +} + +impl NetworkAgentActor { + async fn lookup_link_by_name( + &self, + link_name: &str, + ) -> Result { + self.netlink_handle + .link() + .get() + .match_name(link_name.to_string()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("link {} not found", link_name))? + .wrap_err_with(|| format!("failed to query link {}", link_name)) + } + + fn nft_table_exists(objects: &[NfObject<'_>], table: &str) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Table(existing)) => { + existing.family == NfFamily::IP && existing.name.as_ref() == table + } + _ => false, + }) + } + + fn nft_chain_exists(objects: &[NfObject<'_>], table: &str, chain: &str) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Chain(existing)) => { + existing.family == NfFamily::IP + && existing.table.as_ref() == table + && existing.name.as_ref() == chain + } + _ => false, + }) + } + + fn nft_postrouting_masquerade_exists( + objects: &[NfObject<'_>], + table: &str, + chain: &str, + upstream_iface: &str, + ) -> bool { + objects.iter().any(|object| match object { + NfObject::ListObject(NfListObject::Rule(existing)) => { + existing.family == NfFamily::IP + && existing.table.as_ref() == table + && existing.chain.as_ref() == chain + && matches!( + existing.expr.as_ref(), + [Statement::Match(NftMatch { left, right, op }), Statement::Masquerade(_)] + if *op == Operator::EQ + && matches!( + left, + Expression::Named(NamedExpression::Meta(Meta { key: MetaKey::Oifname })) + ) + && matches!(right, Expression::String(iface) if iface.as_ref() == upstream_iface) + ) + } + _ => false, + }) + } + + // todo: IPv6, refer to libvirt's impl: + // ```nft + // table ip6 libvirt_network { + // chain forward { + // type filter hook forward priority filter; policy accept; + // counter packets 0 bytes 0 jump guest_cross + // counter packets 0 bytes 0 jump guest_input + // counter packets 0 bytes 0 jump guest_output + // } + + // chain guest_output { + // } + + // chain guest_input { + // } + + // chain guest_cross { + // } + + // chain guest_nat { + // type nat hook postrouting priority srcnat; policy accept; + // } + // } + // ``` + /// Ensures the host-only NAT masquerade rule exists for the configured + /// upstream interface. + /// + /// This is intentionally IPv4-only for now. + /// + /// The current host-only networking model is built around IPv4 guest + /// addressing and an IPv4 bridge gateway: + /// - guest subnet config uses `Ipv4Net` + /// - bridge gateway config uses `Ipv4Addr` + /// - bridge address assignment is currently IPv4-only + /// + /// Although nftables can match by interface in `inet` tables, switching + /// this masquerade rule to dual-stack would implicitly opt us into IPv6 + /// NAT/NAT66 policy as well. That is a larger design decision than this + /// hook should make on its own. Until odorobo has an intentional IPv6 + /// guest-networking story, we keep host-only NAT scoped to IPv4. + fn ensure_nat_rules(_bridge: &str, _subnet: &str, upstream_iface: &str) -> Result<(), Report> { + const TABLE_NAME: &str = "odorobo"; + const CHAIN_NAME: &str = "guest_nat"; + + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + upstream_iface = upstream_iface, + "ensuring host-only NAT masquerade rule exists" + ); + + let ruleset = get_current_ruleset().wrap_err("failed to fetch current nftables ruleset")?; + let objects = ruleset.objects.as_ref(); + + let table_exists = Self::nft_table_exists(objects, TABLE_NAME); + let chain_exists = Self::nft_chain_exists(objects, TABLE_NAME, CHAIN_NAME); + let rule_exists = Self::nft_postrouting_masquerade_exists( + objects, + TABLE_NAME, + CHAIN_NAME, + upstream_iface, + ); + + if rule_exists { + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + upstream_iface = upstream_iface, + "existing NAT masquerade rule already present" + ); + return Ok(()); + } + + let mut batch = NftBatch::new(); + + if !table_exists { + info!(table = TABLE_NAME, "creating odorobo nftables table"); + batch.add(NfListObject::Table(NftTable { + family: NfFamily::IP, + name: TABLE_NAME.into(), + handle: None, + })); + } + + if !chain_exists { + info!( + table = TABLE_NAME, + chain = CHAIN_NAME, + "creating odorobo nftables chain" + ); + batch.add(NfListObject::Chain(NftChain { + family: NfFamily::IP, + table: TABLE_NAME.into(), + name: CHAIN_NAME.into(), + newname: None, + handle: None, + _type: Some(NfChainType::NAT), + hook: Some(NfHook::Postrouting), + prio: Some(100), + dev: None, + policy: Some(NfChainPolicy::Accept), + })); + } + + batch.add(NfListObject::Rule(NftRule { + family: NfFamily::IP, + table: TABLE_NAME.into(), + chain: CHAIN_NAME.into(), + expr: vec![ + Statement::Match(NftMatch { + left: Expression::Named(NamedExpression::Meta(Meta { + key: MetaKey::Oifname, + })), + right: Expression::String(upstream_iface.into()), + op: Operator::EQ, + }), + Statement::Masquerade(None), + ] + .into(), + handle: None, + index: None, + comment: Some("odorobo host-only NAT".into()), + })); + + let batch = batch.to_nftables(); + apply_ruleset(&batch).wrap_err_with(|| { + format!( + "failed to apply nftables postrouting masquerade for upstream interface {upstream_iface}" + ) + })?; + + Ok(()) + } +} + +impl Actor for NetworkAgentActor { + type Args = NetworkConfig; + type Error = Report; + async fn on_start(args: Self::Args, actor_ref: ActorRef) -> Result { + // do some netlink fuckery here + + let (connection, handle, _) = rtnetlink::new_connection()?; + let netlink_thread = tokio::spawn(connection); + + let common = match args.network_mode.clone() { + NetworkMode::HostonlyNat { + bridge, + subnet, + gateway: _, + upstream_iface, + } => NetworkConfigCommon { + bridge, + subnet: subnet.to_string(), + upstream_iface: Some(upstream_iface), + }, + NetworkMode::Bridged { + bridge, + subnet, + gateway: _, + } => NetworkConfigCommon { + bridge, + subnet: subnet.to_string(), + upstream_iface: None, + }, + }; + + // ensure the bridge exists, creating it if necessary + let bridge_lookup = handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await; + + let bridge = match bridge_lookup { + Some(Ok(bridge)) => bridge, + Some(Err(NetlinkError::NetlinkError(ref nl_err))) + if nl_err.raw_code() == -libc::ENODEV => + { + info!(bridge = %common.bridge, "bridge not found during lookup, creating it"); + let new_bridge = LinkBridge::new(&common.bridge).up().build(); + handle + .link() + .add(new_bridge) + .execute() + .await + .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; + + handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? + .wrap_err_with(|| { + format!("failed to query bridge {} after creation", common.bridge) + })? + } + Some(Err(err)) => { + return Err(err) + .wrap_err_with(|| format!("failed to query bridge {}", common.bridge)); + } + None => { + info!(bridge = %common.bridge, "creating new bridge"); + let new_bridge = LinkBridge::new(&common.bridge).up().build(); + handle + .link() + .add(new_bridge) + .execute() + .await + .wrap_err_with(|| format!("failed to create bridge {}", common.bridge))?; + + handle + .link() + .get() + .match_name(common.bridge.clone()) + .execute() + .next() + .await + .ok_or_else(|| eyre!("bridge {} was not found after creation", common.bridge))? + .wrap_err_with(|| { + format!("failed to query bridge {} after creation", common.bridge) + })? + } + }; + + let dhcp_actor = if let Some(dhcp_config) = &args.dhcp_config { + Some( + DhcpActor::spawn_link(&actor_ref, (dhcp_config.clone(), common.bridge.clone())) + .await, + ) + } else { + None + }; + + let actor = Self { + config: args, + common, + dhcp_actor, + netlink_thread, + netlink_handle: handle, + }; + + let common_bridge = actor.common.bridge.clone(); + let common_subnet = actor.common.subnet.clone(); + + info!( + bridge = %common_bridge, + subnet = %common_subnet, + upstream_iface = ?actor.common.upstream_iface, + "network actor startup configuration resolved" + ); + + match actor.config.network_mode.clone() { + NetworkMode::HostonlyNat { + bridge: _, + subnet, + gateway, + upstream_iface: _, + } => { + info!( + bridge = %actor.common.bridge, + gateway = %gateway, + prefix_len = subnet.prefix_len(), + "ensuring host-only NAT bridge gateway address" + ); + + ensure_address( + &actor.netlink_handle, + bridge.header.index, + &actor.common.bridge, + gateway, + subnet.prefix_len(), + ) + .await + .wrap_err_with(|| { + format!( + "failed to ensure gateway {}/{} exists on bridge {}", + gateway, + subnet.prefix_len(), + actor.common.bridge + ) + })?; + + let upstream_iface = + actor.common.upstream_iface.as_deref().ok_or_else(|| { + eyre!("host-only NAT mode requires an upstream interface") + })?; + + info!( + bridge = %common_bridge, + upstream_iface = upstream_iface, + "host-only NAT uses routed upstream interface rather than bridge master" + ); + + Self::ensure_nat_rules(&common_bridge, &common_subnet, upstream_iface) + .wrap_err_with(|| { + format!( + "failed to ensure nftables NAT rules for bridge {} and upstream {}", + common_bridge, upstream_iface + ) + })?; + } + NetworkMode::Bridged { + bridge: _, + subnet, + gateway, + } => { + info!( + bridge = %actor.common.bridge, + gateway = %gateway, + prefix_len = subnet.prefix_len(), + "ensuring bridged mode host address on bridge" + ); + + ensure_address( + &actor.netlink_handle, + bridge.header.index, + &actor.common.bridge, + gateway, + subnet.prefix_len(), + ) + .await + .wrap_err_with(|| { + format!( + "failed to ensure gateway {}/{} exists on bridge {}", + gateway, + subnet.prefix_len(), + actor.common.bridge + ) + })?; + } + } + + Ok(actor) + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + reason: ActorStopReason, + ) -> std::result::Result<(), Self::Error> { + match reason { + ActorStopReason::Normal => { + info!(bridge = %self.common.bridge, "stopping network agent"); + } + ActorStopReason::Killed => { + info!(bridge = %self.common.bridge, "network agent killed"); + } + ActorStopReason::Panicked(err) => { + info!(bridge = %self.common.bridge, ?err, "network agent panicked"); + } + _ => { + info!(bridge = %self.common.bridge, "network agent stopping"); + } + } + + if let Some(dhcp_actor) = self.dhcp_actor.take() { + dhcp_actor.stop_gracefully().await?; + } + + self.netlink_thread.abort(); + let _ = (&mut self.netlink_thread).await; + + Ok(()) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: AttachTap, + _ctx: &mut Context, + ) -> Self::Reply { + let bridge = self + .lookup_link_by_name(&self.common.bridge) + .await + .wrap_err_with(|| { + format!( + "failed to resolve bridge {} before attaching tap {}", + self.common.bridge, msg.tap_name + ) + })?; + + let tap = self + .lookup_link_by_name(&msg.tap_name) + .await + .wrap_err_with(|| { + format!("failed to resolve tap {} for vm {}", msg.tap_name, msg.vmid) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "attaching tap to bridge" + ); + + self.netlink_handle + .link() + .set( + LinkUnspec::new_with_index(tap.header.index) + .controller(bridge.header.index) + .build(), + ) + .execute() + .await + .wrap_err_with(|| { + format!( + "failed to attach tap {} to bridge {}", + msg.tap_name, self.common.bridge + ) + })?; + + self.netlink_handle + .link() + .set(LinkUnspec::new_with_index(tap.header.index).up().build()) + .execute() + .await + .wrap_err_with(|| format!("failed to bring tap {} up", msg.tap_name))?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "tap attached to bridge successfully" + ); + + Ok(()) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: DetachTap, + _ctx: &mut Context, + ) -> Self::Reply { + let tap = self + .lookup_link_by_name(&msg.tap_name) + .await + .wrap_err_with(|| { + format!( + "failed to resolve tap {} for detach on vm {}", + msg.tap_name, msg.vmid + ) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "detaching tap from bridge" + ); + + self.netlink_handle + .link() + .set( + LinkUnspec::new_with_index(tap.header.index) + .nocontroller() + .build(), + ) + .execute() + .await + .wrap_err_with(|| { + format!( + "failed to detach tap {} from bridge {}", + msg.tap_name, self.common.bridge + ) + })?; + + info!( + vmid = %msg.vmid, + tap = %msg.tap_name, + bridge = %self.common.bridge, + "tap detached from bridge successfully" + ); + + Ok(()) + } +} diff --git a/odorobo/src/networking/actor_unsupported.rs b/odorobo/src/networking/actor_unsupported.rs new file mode 100644 index 0000000..79bf0d7 --- /dev/null +++ b/odorobo/src/networking/actor_unsupported.rs @@ -0,0 +1,57 @@ +use crate::config::NetworkConfig; +use crate::networking::messages::{AttachTap, DetachTap}; +use kameo::{message::Context, prelude::*}; +use stable_eyre::Report; +use stable_eyre::eyre::eyre; +use tracing::warn; + +#[derive(RemoteActor)] +pub struct NetworkAgentActor { + pub config: NetworkConfig, +} + +impl Actor for NetworkAgentActor { + type Args = NetworkConfig; + type Error = Report; + + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + warn!( + target_os = std::env::consts::OS, + "host networking management is only supported on Linux" + ); + + Ok(Self { config: args }) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: AttachTap, + _ctx: &mut Context, + ) -> Self::Reply { + Err(eyre!( + "cannot attach TAP {} for VM {}: host networking management is only supported on Linux", + msg.tap_name, + msg.vmid + )) + } +} + +impl Message for NetworkAgentActor { + type Reply = Result<(), Report>; + + async fn handle( + &mut self, + msg: DetachTap, + _ctx: &mut Context, + ) -> Self::Reply { + Err(eyre!( + "cannot detach TAP {} for VM {}: host networking management is only supported on Linux", + msg.tap_name, + msg.vmid + )) + } +} diff --git a/odorobo/src/networking/mod.rs b/odorobo/src/networking/mod.rs index 6608fae..7e3c38e 100644 --- a/odorobo/src/networking/mod.rs +++ b/odorobo/src/networking/mod.rs @@ -1,2 +1,8 @@ pub mod actor; + +#[cfg(target_os = "linux")] +mod actor_linux; +#[cfg(not(target_os = "linux"))] +mod actor_unsupported; + pub mod messages; diff --git a/odorobo/src/utils/actor_cache.rs b/odorobo/src/utils/actor_cache.rs index 015e18e..50415ab 100644 --- a/odorobo/src/utils/actor_cache.rs +++ b/odorobo/src/utils/actor_cache.rs @@ -44,9 +44,17 @@ pub struct ActorCache< ChildActor: Actor + RemoteActor, Data: Clone + Send + Sync + 'static + fmt::Debug, > { + #[expect( + dead_code, + reason = "keeps the parent actor reference alive for cache-owned tasks" + )] parent_actor_ref: ActorRef, pub data_cache: Arc>, keepalive_tasks: Arc>>, + #[expect( + dead_code, + reason = "retains the background finder task handle for future shutdown/drop handling" + )] actor_finder: Option>, child_actor_type: PhantomData, @@ -69,8 +77,8 @@ impl< let actor_cache = ActorCache { parent_actor_ref: parent_actor_ref.clone(), - data_cache: data_cache, - keepalive_tasks: keepalive_tasks, + data_cache, + keepalive_tasks, actor_finder: None, child_actor_type: PhantomData, diff --git a/odorobo/src/utils/mod.rs b/odorobo/src/utils/mod.rs index 0326c3a..a874761 100644 --- a/odorobo/src/utils/mod.rs +++ b/odorobo/src/utils/mod.rs @@ -103,6 +103,10 @@ pub fn init(debug_target: Option<&str>) -> Result<()> { Ok(()) } +#[expect( + dead_code, + reason = "convenience initializer for binaries/tests that do not need a debug target" +)] pub fn init_default() -> Result<()> { init(None) } From 6365657d2a066de9c8288d7c07781885087afce4 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 17:33:07 -0600 Subject: [PATCH 184/189] fix a couple issues --- .github/workflows/rust.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d3678c0..fce3635 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -29,7 +29,8 @@ jobs: - name: Run rustfmt run: cargo fmt -- --check - clippy: + + rust-clippy-analyze: permissions: security-events: write if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) @@ -62,6 +63,7 @@ jobs: with: sarif_file: rust-clippy-results.sarif wait-for-processing: true + test-x86: if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) runs-on: ubuntu-latest From 0f23e370d2e382eb13f43d9ccadd42e7278d1835 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 20:13:07 -0600 Subject: [PATCH 185/189] removed rva23 tests bc we don't have emulation --- .github/workflows/rust.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fce3635..2852f1a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -139,5 +139,5 @@ jobs: path: | target/riscv64a23-unknown-linux-gnu/release/odoroboctl - - name: Test - run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo test --verbose + - name: Build tests + run: podman run --rm -v ${{ github.workspace }}:/code -w /code odorobo-builder:rva23 cargo test --verbose --no-run From 1fae5aace8687ab6630507032b30282a27a9abf7 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 20:18:32 -0600 Subject: [PATCH 186/189] add note to readme --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8b05fc8..1cbd76a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Stack Virtualization Platform - Powered by Cloud Hypervisor -Odorobo(robot dance) is a hypervisor platform built on top of -[Cloud Hypervisor](https://www.cloudhypervisor.org/), a lightweight +Odorobo(robot dance) is a hypervisor platform built on top of +[Cloud Hypervisor](https://www.cloudhypervisor.org/), a lightweight VMM built on rust-vmm, with a focus on providing lightweight, stateful, and portable virtual machines. @@ -74,3 +74,7 @@ See [docs/console.md](docs/console.md) for direct serial socket access, WebSocke For more advanced usage, Odorobo Agent also exposes a passthrough route for the local Cloud Hypervisor API, allowing you to call the full Cloud Hypervisor API directly through the agent's REST API See `docs/ch-passthrough.md` for Cloud Hypervisor API passthrough usage. + +## Note about CI Tests + +Because we don't have an RVA23 runner, the `test-riscv-rva23` workflow will not actually run the test suite. It will only build the tests and verify they compile successfully. From 7ce613039412b0c77cfd65c61f2476d5da591297 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 21:22:46 -0600 Subject: [PATCH 187/189] split clippy into a separate yaml for codeql --- .github/workflows/clippy.yml | 41 ++++++++++++++++++++++++++++++++++++ .github/workflows/rust.yml | 31 ++------------------------- 2 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/clippy.yml diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml new file mode 100644 index 0000000..b1d8168 --- /dev/null +++ b/.github/workflows/clippy.yml @@ -0,0 +1,41 @@ +name: Clippy +permissions: + contents: read +on: + workflow_call: + +jobs: + rust-clippy-analyze: + permissions: + contents: read + security-events: write + if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 + with: + toolchain: stable + components: clippy + + - name: Install required cargo + run: cargo install clippy-sarif sarif-fmt + + - name: Run rust-clippy + run: cargo clippy + --all-features + --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt + continue-on-error: true + env: + CARGO_TERM_VERBOSE: false + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: rust-clippy-results.sarif + wait-for-processing: true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2852f1a..6c55c11 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,38 +31,11 @@ jobs: run: cargo fmt -- --check rust-clippy-analyze: + uses: ./.github/workflows/clippy.yml permissions: + contents: read security-events: write - if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - submodules: recursive - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 - with: - toolchain: stable - components: clippy - - - name: Install required cargo - run: cargo install clippy-sarif sarif-fmt - - - name: Run rust-clippy - run: cargo clippy - --all-features - --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt - continue-on-error: true - env: - CARGO_TERM_VERBOSE: false - - - name: Upload analysis results to GitHub - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - sarif_file: rust-clippy-results.sarif - wait-for-processing: true test-x86: if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) From 08fbfbd35fbb4493122bde15d27c3121bbd0daf0 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 21:24:34 -0600 Subject: [PATCH 188/189] actually nvm it's part of codeql now --- .github/workflows/clippy.yml | 41 ---------------------------------- .github/workflows/codeql.yml | 43 ++++++++++++++++++++++++++++++++++++ .github/workflows/rust.yml | 7 ------ 3 files changed, 43 insertions(+), 48 deletions(-) delete mode 100644 .github/workflows/clippy.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml deleted file mode 100644 index b1d8168..0000000 --- a/.github/workflows/clippy.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Clippy -permissions: - contents: read -on: - workflow_call: - -jobs: - rust-clippy-analyze: - permissions: - contents: read - security-events: write - if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - submodules: recursive - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 - with: - toolchain: stable - components: clippy - - - name: Install required cargo - run: cargo install clippy-sarif sarif-fmt - - - name: Run rust-clippy - run: cargo clippy - --all-features - --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt - continue-on-error: true - env: - CARGO_TERM_VERBOSE: false - - - name: Upload analysis results to GitHub - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - with: - sarif_file: rust-clippy-results.sarif - wait-for-processing: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..42aa4ff --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: CodeQL + +permissions: + contents: read + security-events: write + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * 0" + +jobs: + analyze: + name: CodeQL Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: rust + + - name: Autobuild + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + + rust-clippy-analyze: + name: Clippy SARIF + uses: ./.github/workflows/clippy.yml + permissions: + contents: read + security-events: write diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6c55c11..76fd11d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,13 +30,6 @@ jobs: - name: Run rustfmt run: cargo fmt -- --check - rust-clippy-analyze: - uses: ./.github/workflows/clippy.yml - permissions: - contents: read - security-events: write - - test-x86: if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) runs-on: ubuntu-latest From 288bb0cf6312a7e08d2ab9766925d1991ad50c39 Mon Sep 17 00:00:00 2001 From: Willow Reed Date: Tue, 7 Jul 2026 21:32:15 -0600 Subject: [PATCH 189/189] accidentally deleted clippy.yml --- .github/workflows/clippy.yml | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/clippy.yml diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml new file mode 100644 index 0000000..b1d8168 --- /dev/null +++ b/.github/workflows/clippy.yml @@ -0,0 +1,41 @@ +name: Clippy +permissions: + contents: read +on: + workflow_call: + +jobs: + rust-clippy-analyze: + permissions: + contents: read + security-events: write + if: (github.event_name != 'pull_request' && ! github.event.pull_request.head.repo.fork) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 # v1.94.1 + with: + toolchain: stable + components: clippy + + - name: Install required cargo + run: cargo install clippy-sarif sarif-fmt + + - name: Run rust-clippy + run: cargo clippy + --all-features + --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt + continue-on-error: true + env: + CARGO_TERM_VERBOSE: false + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: rust-clippy-results.sarif + wait-for-processing: true