diff --git a/Cargo.lock b/Cargo.lock index d36ae478..1cd0b5d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -926,6 +927,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "clap_lex" version = "0.7.7" @@ -968,7 +981,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1608,6 +1621,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1615,7 +1639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2657,7 +2681,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2796,7 +2820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "642883fdc81cf2da15ee8183fa1d2c7da452414dd41541a0f3e1428069345447" dependencies = [ "scopeguard", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2939,6 +2963,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" name = "libmodcore" version = "0.1.0" dependencies = [ + "clap", "colored", "indexmap 2.13.0", "libsysinspect", @@ -2947,6 +2972,8 @@ dependencies = [ "serde_json", "serde_yaml", "shlex", + "strum 0.27.2", + "strum_macros 0.27.2", "textwrap", ] @@ -2972,6 +2999,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "textwrap", "tokio", ] @@ -3226,6 +3254,42 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lua-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "libmodcore", + "libsysinspect", + "log", + "mlua", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", +] + +[[package]] +name = "lua-src" +version = "548.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc4e1aff422ad5f08cffb4719603dcdbc2be2307f4c1510d7aab74b7fa88ca8" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.4+e17ee83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a0ceb2a395ffa403a863adcf365e82cc8d8338ac7f5f949b9df5ca3de251e1" +dependencies = [ + "cc", + "which", +] + [[package]] name = "lz4_flex" version = "0.12.0" @@ -3435,6 +3499,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mlua" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935ac67539907efcd7198137eb7358e052555f77fe1b2916600a2249351f2b33" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot 0.12.5", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c968af21bf6b19fc9ca8e7b85ee16f86e4c9e3d0591de101a5608086bda0ad8" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "mt19937" version = "3.1.0" @@ -3568,7 +3664,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3877,6 +3973,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "page_size" version = "0.6.0" @@ -4447,7 +4552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -4467,7 +4572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -5197,7 +5302,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5263,7 +5368,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5815,6 +5920,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6509,7 +6624,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7036,6 +7151,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.19.0" @@ -7602,7 +7723,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 22b03e88..221a8924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ openssl = { version = "0.10.75", features = ["vendored"] } resolver = "2" members = [ "modules/sys/*", + "modules/runtime/*", "libsysinspect", "libeventreg", "sysmaster", diff --git a/Makefile b/Makefile index 4a2b07f4..5a047766 100644 --- a/Makefile +++ b/Makefile @@ -40,11 +40,14 @@ define move_bin mkdir -p $$dir/sys; \ rm -rf $$dir/fs; \ mkdir -p $$dir/fs; \ + rm -rf $$dir/runtime; \ + mkdir -p $$dir/runtime; \ mv $$dir/proc $$dir/sys/; \ mv $$dir/net $$dir/sys/; \ mv $$dir/run $$dir/sys/; \ mv $$dir/ssrun $$dir/sys/; \ - mv $$dir/file $$dir/fs/; + mv $$dir/file $$dir/fs/; \ + mv $$dir/lua-runtime $$dir/runtime/; endef setup: diff --git a/docs/conf.py b/docs/conf.py index 1a07c728..1347bd29 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,8 @@ project = "Sysinspect" -copyright = "2024, Bo Maryniuk" +copyright = "2026, Bo Maryniuk" author = "Bo Maryniuk" version = "0.4.0" -release = "Alpha" +# release = "Alpha" extensions = [ "myst_parser", diff --git a/docs/global_config.rst b/docs/global_config.rst index 3b3d3bc9..593d53e4 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -557,6 +557,23 @@ Example configuration for the Sysinspect Minion: master.ip: 192.168.2.31 master.port: 4200 +``log.forward`` +################## + + Type: **boolean** + + Forward logs from actions and modules to the main sysinspect log, landing them in the main log file. + If disabled, logs from actions and modules will not be forwarded to the main sysinspect log but are kept + within their own context inside the returned data and will travel across the whole network back to the master. + + Thus, disabling this feature on a large cluster might inflate your network traffic so much that your network + admin will start believe in ghosts and aliens. + + .. warning:: + + Disable this option only if you really know what you are doing. + + Default is ``true`` Layout of ``/etc/sysinspect`` ----------------------------- diff --git a/docs/index.rst b/docs/index.rst index 258dd73d..6c63ff2e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,6 +45,7 @@ welcome—see the section on contributing for how to get involved. tutorial/cfgmgmt_tutor tutorial/action_chain_tutor tutorial/module_management + tutorial/lua_modules_tutor Licence diff --git a/docs/moddescr/overview.rst b/docs/moddescr/overview.rst index d3e46eac..2b67024b 100644 --- a/docs/moddescr/overview.rst +++ b/docs/moddescr/overview.rst @@ -36,10 +36,10 @@ programs etc. However there are few rules that needs to be complied: Refer to the detailed communication protocol documentation in chapter :ref:`commproto`. -Available modules ------------------ +Control Modules +--------------- -Below is a list of available modules and their documentation: +Below is a list of available control modules and their documentation: .. toctree:: :maxdepth: 1 @@ -48,4 +48,23 @@ Below is a list of available modules and their documentation: sys_net sys_run sys_ssrun - fs_file \ No newline at end of file + fs_file + +Runtime Modules +---------------- + +Runtime modules are the same control modules as usual, but they are simply additionally +running specific targets they are meant to run. For example, WASM runtime, Python runtime, Lua etc. +These modules are enabling certain types of user-written modules to be executed +inside Sysinspect ecosystem. + +This is done for the reason of security, isolation, portability and customisation. +For example, there are cases where no Python interpreter is needed at all, so user +can simply remove that module from the Minion in a whole. + +Below is a list of available runtime modules and their documentation: + +.. toctree:: + :maxdepth: 1 + + runtime_lua \ No newline at end of file diff --git a/docs/moddescr/runtime_lua.rst b/docs/moddescr/runtime_lua.rst new file mode 100644 index 00000000..9b29c3ee --- /dev/null +++ b/docs/moddescr/runtime_lua.rst @@ -0,0 +1,80 @@ +``runtime.lua`` +=============== + +.. note:: + + This document describes ``runtime.lua`` module usage. + +Lua runtime +----------- + +``runtime.lua`` is the SysInspect Lua runtime module. It enables you to execute Lua scripts as SysInspect +modules, which makes it convenient to extend inspections with small, self-contained checks written in Lua. + +At a high level, the runtime: + +* discovers Lua scripts in the configured scripts directory, +* executes a selected script (module) by name, +* optionally forwards keyword arguments to the script as global variables, +* can expose logging and native library loading depending on the configured options. + +Script lookup and naming +~~~~~~~~~~~~~~~~~~~~~~~~ + +When you set the required keyword argument ``rt.mod``, SysInspect searches for a Lua file with that module +name in the predefined, configured scripts directory. + +The runtime treats the script as an entry point. Keep the script focused on one task and prefer importing +shared helpers from the dependency directory described below. + +Directory layout (Lua 5.4) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To keep module scripts and their dependencies predictable, install files into the following locations +under ``${SYSINSPECT_SHARELIB_ROOT}``: + +1. **Main scripts (modules)** + + Install entry-point scripts into:: + + lib/runtime/lua54/ + +2. **Dependency libraries** + + Install Lua libraries required by your scripts into:: + + lib/runtime/lua54/site-lua/ + +This separation keeps your callable modules easy to list and prevents helper libraries from being treated +as top-level SysInspect modules. + +Options +------- + +``rt.list`` + List available Lua scripts that can be called as modules. + +``rt.logs`` + Enable logging from Lua scripts into SysInspect logs. Use this for diagnostics and traceability. + +``rt.native`` + Enable loading of native Lua libraries (C modules). Use with caution, since native modules run with the + same privileges as the SysInspect process and can widen the runtime's attack surface. + +Keyword arguments +----------------- + +``rt.mod`` (type: string, required) + The name of the Lua script to execute. The runtime looks it up in the configured scripts directory. + +``[ANY]`` (type: string) + Additional keyword arguments forwarded to the executed script. These values are made available to the + script as global variables. + +Practical notes +--------------- + +* Keep argument names unambiguous, since they become globals in the script. +* Prefer passing strings and parsing them in Lua when you need structured values. +* Use ``rt.logs`` while developing scripts, then disable it if you want quieter operation. + diff --git a/docs/tutorial/lua_modules_tutor.rst b/docs/tutorial/lua_modules_tutor.rst new file mode 100644 index 00000000..0c2f573d --- /dev/null +++ b/docs/tutorial/lua_modules_tutor.rst @@ -0,0 +1,270 @@ +.. raw:: html + + + +.. role:: u + :class: underlined + +.. role:: bi + :class: bolditalic + +.. _lua_tutorial: + +Using Lua Modules +================= + +.. note:: + This tutorial shows how to run Lua code via Sysinspect by installing a Lua runtime module and + publishing Lua scripts as modules. It assumes you have a working Sysinspect installation and basic + familiarity with Sysinspect concepts such as modules, models, actions, and entities. + +Prerequisites +----------------- + +Before you begin, ensure you have the following prerequisites: + +- A working installation of Sysinspect. +- Basic Lua knowledge (functions, modules, returning tables). +- Access to the SysMaster node (the node hosting the package repository). +- Permission to register modules and to sync the cluster. + +Installing Lua Runtime +---------------------- + +Sysinspect runs scripts through *runtime modules*. A runtime module is a normal Sysinspect module +that ships an interpreter (Lua in this case) plus any runtime glue needed to execute your scripts. + +To run Lua scripts, Sysinspect must have a Lua runtime module available in the repository. + +Build the runtime +^^^^^^^^^^^^^^^^^ + +If you build Sysinspect from source, build the project from the top-level directory: + +.. code-block:: bash + + make + +After a successful build, the Lua runtime binary is typically located here: + +.. code-block:: bash + + $SRC/target/release/runtime/lua-runtime + +(Use ``target/debug/...`` if you built a debug configuration.) + +Register the runtime +^^^^^^^^^^^^^^^^^^^^ + +Register the runtime on the SysMaster so it becomes available in the module repository. +Replace the path with the actual path to your built binary: + +.. code-block:: bash + + sysinspect module -A \ + --path /path/to/your/target/release/runtime/lua-runtime \ + --name "runtime.lua-runtime" \ + --descr "Lua runtime" + +What this does: + +1. ``--path`` points to the runtime binary you built. +2. ``--name`` is the module name you will reference from models. +3. ``--descr`` is a human-readable description (optional). + +This adds the runtime into SysMaster's package repository. You can then verify the module is registered +by listing all available modules: + +.. code-block:: bash + + sysinspect module -L + +After registering the module, the cluster **needs to be synced** so all nodes receive the new module metadata: + +.. code-block:: bash + + sysinspect --sync + +Once the runtime is registered and the cluster is synced, you can publish Lua scripts that will run +through that runtime. + + +Installing Lua Modules +---------------------- + +Sysinspect can ship plain script files as modules. For Lua, the current packaging style is: + +* You upload a directory tree (usually named ``lib``). +* Sysinspect preserves the directory structure. +* Those scripts are treated as a library attached to the Lua runtime. + +Directory layout +^^^^^^^^^^^^^^^^ + +Assume your local working directory contains a ``lib`` directory with Lua scripts under a +runtime-specific path, for example: + +.. code-block:: text + + lib/ + runtime/ + lua54/ + hello.lua + reader.lua + caller.lua + site-lua/ + mathx/ + init.lua + extra.lua + +The important part is: upload the *directory* (``./lib``), not individual files, so the runtime +sees the same module paths on all nodes. + +To maintain your set of Lua scripts, you can create your own directory tree like the one above. +Make changes you need and then re-upload the entire ``./lib`` directory to update the scripts +in the repository. It will overwrite existing scripts with the same paths. + + +Publish the scripts +^^^^^^^^^^^^^^^^^^^ + +Example scripts are provided in the ``modules/runtime/lua-runtime/examples`` directory of the Sysinspect +source tree. You can freely modify or extend these scripts for your own use. Assuming you navigated to that +directory, mentioned above, and have a ``./lib`` directory, publish the scripts like this: + +1. Add the library tree to the repository (run this from the directory that contains ``./lib``): + + .. code-block:: bash + + sysinspect module -A --path ./lib -l + + The ``-l`` flag tells Sysinspect you are adding a library directory. + + .. important:: + + Take a look into the structure of the ``./lib`` directory before uploading. + All runtime modules are typically going to ``runtime/`` subdirectories. + All dependencies are typically under ``site-`` subdirectories. + + Always upload the *directory* (``./lib``), not individual files, so the runtime + sees the same module paths on all nodes. + +2. Sync the cluster so all nodes receive the new module metadata: + + .. code-block:: bash + + sysinspect --sync + +3. Verify that the scripts are visible: + + .. code-block:: bash + + sysinspect module -Ll + + Example output (yours might be different): + + .. code-block:: text + + Type Name OS Arch SHA256 + ────────────────────────────────────────────────────────────────────────── + script runtime/lua54/caller.lua Any noarch 7aff...d8c5 + script runtime/lua54/hello.lua Any noarch 22ce...f2e1 + script runtime/lua54/reader.lua Any noarch 8ce3...0135 + script runtime/lua54/site-lua/mathx/extra.lua Any noarch 92ce...79e3 + script runtime/lua54/site-lua/mathx/init.lua Any noarch f636...f314 + +Once you've done this, the Lua scripts are available to be called from models. +There are three modules in the example set: +- ``hello.lua``: A demonstration of using packages and modules (``site-lua/mathx`` in this case). +- ``reader.lua``: Reads the ``VERSION`` field from ``/etc/os-release`` and returns it. +- ``caller.lua``: Demonstrates calling an external program from Lua and capturing its output. + +This set of examples although minimal, is more than enough to produce simple powerful +configuration management modules, capable of being extended for any use case. + +Calling a Lua module from a model +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In current implementation, you :bi:`do not call Lua modules directly` from actions. To execute these Lua +modules, you reference the runtime module (``runtime.lua-runtime``) from an action. Your action passes +arguments telling the runtime which Lua module to invoke. + +Example model snippet: + +.. code-block:: yaml + + entities: + - foo + + actions: + my-example: + descr: Read VERSION from /etc/os-release via Lua + module: runtime.lua-runtime + bind: + - foo + state: + $: + opts: + # Enable logging from Lua scripts to SysInspect logs + # Logs will be included in the action result under 'logs' namespace + - rt.logs + args: + rt.mod: reader + +The confusing part here is the ``rt.mod`` argument under ``opts.args``. All arguments with the ``rt.*`` +prefix are special runtime arguments that the Lua runtime module understands and are **not** passed to +the Lua script. Any other arguments (without the ``rt.*`` prefix) are directly passed to the Lua script +as normal arguments. + +.. important:: + + The ``rt.*`` arguments are runtime-specific. Different runtimes may have different + ``rt.*`` arguments. To know them, run the embedded runtime manpage command. In this case, + if you have a standard installation, run this command on the **SysMinion** node: + + .. code-block:: bash + + /usr/lib/sysinspect/runtime/lua-runtime --man + +In this example, ``mod: reader`` means you would run the Lua module implemented by ``reader.lua``. +What *exactly* the Lua runtime expects for module naming depends on the runtime implementation, +but the intent is: keep your script name stable and call it by module name. + +Run the action +^^^^^^^^^^^^^^ + +Execute the action against a node (minion) like this: + +.. code-block:: bash + + sysinspect yourmodel/foo yourminion + +Where: + +* ``yourmodel`` is the model directory/name. +* ``foo`` is the bound entity instance. +* ``yourminion`` is the hostname. + +To target all nodes, you can use ``*``. Once executed, you can also check the action result +by invokindg the Sysinspect terminal UI, used for merely checking if the results came back correctly: + +.. code-block:: bash + + sysinspect --ui + +Troubleshooting +^^^^^^^^^^^^^^^ + +* If the runtime is missing, confirm ``runtime.lua-runtime`` appears in ``sysinspect module -L``. +* If scripts are missing, confirm you uploaded ``./lib`` (the directory) and re-ran + ``sysinspect --sync``. +* If module imports fail, verify your ``lib/runtime/lua54/...`` layout matches what the runtime + expects, and that any ``site-lua`` modules are located under the same tree. diff --git a/libmodcore/Cargo.toml b/libmodcore/Cargo.toml index 758997b3..bb744f1c 100644 --- a/libmodcore/Cargo.toml +++ b/libmodcore/Cargo.toml @@ -13,3 +13,6 @@ serde_yaml = "0.9.34" shlex = "1.3.0" textwrap = "0.16.2" libsysinspect = { path = "../libsysinspect" } +clap = { version = "4.5.53", features = ["derive"] } +strum = "0.27.2" +strum_macros = "0.27.2" diff --git a/libmodcore/src/lib.rs b/libmodcore/src/lib.rs index e7d7e98c..cf79368b 100644 --- a/libmodcore/src/lib.rs +++ b/libmodcore/src/lib.rs @@ -1,8 +1,12 @@ use indexmap::IndexMap; use shlex::Shlex; +pub mod manrndr; +pub mod modcli; pub mod modinit; pub mod response; +pub mod rtdocschema; +pub mod rtspec; pub mod runtime; pub mod tpl; diff --git a/libmodcore/src/manrndr.rs b/libmodcore/src/manrndr.rs new file mode 100644 index 00000000..b6f2f48b --- /dev/null +++ b/libmodcore/src/manrndr.rs @@ -0,0 +1,230 @@ +use colored::Colorize; +use serde::Deserialize; +use serde::de::{Deserializer, Error}; +use serde_json::Value::{self, Array, Null, Object}; + +#[derive(Debug, Deserialize)] +pub struct ModuleDoc { + pub name: String, + + /// Optional metadata + #[serde(default)] + pub version: Option, + #[serde(default)] + pub author: Option, + + /// Required per your validator + pub description: String, + + /// Optional sections + #[serde(default, deserialize_with = "unvec")] + pub arguments: Vec, + + #[serde(default, deserialize_with = "unvec")] + pub options: Vec, + + #[serde(default, deserialize_with = "unvec")] + pub examples: Vec, + + #[serde(default)] + pub returns: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DocParam { + pub name: String, + + /// optional in your validator + #[serde(rename = "type", default)] + pub ty: Option, + + #[serde(default)] + pub required: bool, + + /// Lua doc uses "description" + #[serde(default)] + pub description: String, + + /// Not in your current schema, but keep compatibility if someone adds it + #[serde(default)] + pub default: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DocOption { + pub name: String, + + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct DocExample { + pub code: String, + + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct DocReturns { + #[serde(default)] + pub description: String, + + /// Can be anything JSON-ish + #[serde(default)] + pub sample: Option, +} + +fn unvec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + match Value::deserialize(deserializer)? { + Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for item in arr { + out.push(T::deserialize(item).map_err(Error::custom)?); + } + Ok(out) + } + Object(obj) if obj.is_empty() => Ok(vec![]), + Null => Ok(vec![]), + other => Err(Error::custom(format!("expected array ([]) or empty object ({{}}), got {other}"))), + } +} + +pub fn print_mod_manual(doc_val: Value) { + let doc: ModuleDoc = match serde_json::from_value(doc_val) { + Ok(d) => d, + Err(err) => { + eprintln!("Failed to parse module doc: {err}"); + return; + } + }; + + // ---- Header ---- + println!("{}:\n {}\n", "Name".bright_yellow(), doc.name.yellow()); + + if doc.version.is_some() || doc.author.is_some() { + println!("{}:", "Meta".bright_yellow()); + if let Some(v) = &doc.version { + println!(" {} {}", "Version:".bright_white(), v.white()); + } + if let Some(a) = &doc.author { + println!(" {} {}", "Author:".bright_white(), a.white()); + } + println!(); + } + + println!("{}:", "Description".bright_yellow()); + for line in doc.description.trim().lines() { + println!(" {}", line.yellow()); + } + println!(); + + // ---- Arguments ---- + if !doc.arguments.is_empty() { + println!("{}", "Keyword arguments:".bright_yellow()); + print_args(&doc.arguments); + } + + // ---- Options ---- + if !doc.options.is_empty() { + println!("{}", "Options:".bright_yellow()); + print_options(&doc.options); + } + + // ---- Examples ---- + if !doc.examples.is_empty() { + println!("{}", "Usage examples:".bright_yellow()); + for (i, ex) in doc.examples.iter().enumerate() { + if !ex.description.trim().is_empty() { + print!(" {} ", ((i + 1).to_string() + ".").yellow()); + for (j, line) in ex.description.trim().lines().enumerate() { + println!("{}{}", if j == 0 { "" } else { " " }, line.yellow()); + } + } else { + println!(" Example {}", (i + 1).to_string().yellow()); + } + for line in ex.code.trim().lines() { + println!(" {}", line.bright_white()); + } + println!(); + } + } + + // ---- Returns ---- + if let Some(ret) = &doc.returns { + println!("{}", "Returns:".bright_yellow()); + + if !ret.description.trim().is_empty() { + for line in ret.description.trim().lines() { + println!(" {}", line.yellow()); + } + } + + if let Some(sample) = &ret.sample { + match serde_json::to_string_pretty(sample) { + Ok(s) => { + for line in s.trim().lines() { + println!(" {}", line.bright_white()); + } + } + Err(_) => println!(" {}", "".bright_red()), + } + } + + println!(); + } +} + +fn print_args(params: &[DocParam]) { + let name_width = params.iter().map(|p| p.name.len()).max().unwrap_or(0); + + for p in params { + // Build metadata like: "type: string, required" or "type: string, default: foo" + let mut meta = String::new(); + + if let Some(ty) = &p.ty { + meta.push_str(&format!("type: {}", ty)); + } else { + meta.push_str("type: "); + } + + if p.required { + meta.push_str(", required"); + } else if let Some(def) = &p.default { + meta.push_str(&format!(", default: {}", def)); + } + + let meta_col = meta.magenta(); + + println!(" {:width$} ({})", p.name.bright_green().bold(), meta_col, width = name_width); + + if !p.description.trim().is_empty() { + for line in p.description.lines() { + println!(" {}", line.bright_white()); + } + } + + println!(); + } +} + +fn print_options(opts: &[DocOption]) { + let name_width = opts.iter().map(|o| o.name.len()).max().unwrap_or(0); + + for o in opts { + println!(" {:width$}", o.name.bright_green().bold(), width = name_width); + + if !o.description.trim().is_empty() { + for line in o.description.lines() { + println!(" {}", line.bright_white()); + } + } + + println!(); + } +} diff --git a/libmodcore/src/modcli.rs b/libmodcore/src/modcli.rs new file mode 100644 index 00000000..1160bf69 --- /dev/null +++ b/libmodcore/src/modcli.rs @@ -0,0 +1,95 @@ +use clap::{ + Parser, + builder::{ + Styles, + styling::{AnsiColor, Style}, + }, +}; +pub static SHARELIB: &str = "/usr/share/sysinspect/lib/{}"; // Add runtime ID + +pub fn monokai_style() -> Styles { + Styles::styled() + // section headers: "USAGE", "OPTIONS" + .header( + Style::new() + .fg_color(Some(AnsiColor::Yellow.into())) + .bold(), + ) + // the "Usage:" line content + .usage( + Style::new() + .fg_color(Some(AnsiColor::Magenta.into())) + .bold(), + ) + // flags and literals: `-m`, `--man`, `--help-on` + .literal( + Style::new() + .fg_color(Some(AnsiColor::Green.into())), + ) + // metavars / value names: ``, `` + .placeholder( + Style::new() + .fg_color(Some(AnsiColor::BrightYellow.into())), // faux-orange + ) +} + +#[derive(Parser, Debug)] +#[command( + name = env!("CARGO_PKG_NAME"), + version = env!("CARGO_PKG_VERSION"), + about = env!("CARGO_PKG_DESCRIPTION"), + color = clap::ColorChoice::Always, + styles = monokai_style(), + override_usage = format!("{} [OPTIONS] < ", env!("CARGO_PKG_NAME")) +)] +/// CLI definition +pub struct ModuleCli { + /// Show this runtime module operational manual + #[arg(short = 'm', long = "man", alias = "manual")] + man: bool, + + /// List available runtime modules + #[arg(short = 'l', long = "list-modules", alias = "list-plugins")] + modules: bool, + + /// Path where runtime modules are located. Default: /usr/share/sysinspect/lib/{runtime-id} + #[arg(short = 's', long = "sharelib", value_name = "PATH")] + sharelib: Option, + + /// Show operational manual for specific module + #[arg(short = 'i', long = "info", value_name = "MODULE")] + help_on: Option, +} + +/// CLI definition implementation +impl ModuleCli { + /// Get sharelib path + /// # Returns + /// Sharelib path + pub fn get_sharelib(&self) -> String { + self.sharelib.clone().unwrap_or_else(|| SHARELIB.to_string()) + } + + /// Is manual requested + /// # Returns + /// `true` if manual requested + /// `false` otherwise + pub fn is_manual(&self) -> bool { + self.man + } + + /// Is list plugins requested + /// # Returns + /// `true` if list plugins requested + /// `false` otherwise + pub fn is_list_modules(&self) -> bool { + self.modules + } + + /// Get help on specific module + /// # Returns + /// Help on module string + pub fn get_help_on(&self) -> String { + self.help_on.clone().unwrap_or_default() + } +} diff --git a/libmodcore/src/modinit.rs b/libmodcore/src/modinit.rs index ac214e92..066f5222 100644 --- a/libmodcore/src/modinit.rs +++ b/libmodcore/src/modinit.rs @@ -1,6 +1,9 @@ use colored::Colorize; use indexmap::IndexMap; -use libsysinspect::util::dataconv; +use libsysinspect::util::{ + dataconv, + tty::{indent_block, render_markup}, +}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -109,6 +112,7 @@ pub struct ModInterface { options: Vec, arguments: Vec, examples: Vec, + manpage: Option, // Optional full manpage text with additional details, if needed // Map of flags/args to output data structure returns: IndexMap, @@ -187,13 +191,17 @@ impl ModInterface { let dsc_title = "Description:".bright_yellow(); let ex_title = "Usage examples:".bright_yellow(); let ex_code = self.examples.iter().map(|e| e.format()).collect::>().join("\n"); + let manpage = match &self.manpage { + Some(m) => format!("\n\n{}", indent_block(&render_markup(m), " ")), + None => "".to_string(), + }; format!( "{}, {} (Author: {}) {dsc_title} - {} + {}{} {} @@ -204,6 +212,7 @@ impl ModInterface { self.version.green().bold(), self.author, fill(&self.description, Options::new(H_WIDTH).subsequent_indent(" ")).yellow(), + manpage, args(self), ex_code, returns(self), diff --git a/libmodcore/src/rtdocschema.rs b/libmodcore/src/rtdocschema.rs new file mode 100644 index 00000000..f8cd80fa --- /dev/null +++ b/libmodcore/src/rtdocschema.rs @@ -0,0 +1,140 @@ +use crate::rtspec::{RuntimeModuleDocPrefix, RuntimeModuleDocumentation, RuntimeSpec}; +use libsysinspect::SysinspectError; +use serde_json::Value as JsonValue; + +fn expect_string<'a>(v: &'a JsonValue, path: &str) -> Result<&'a str, SysinspectError> { + v.as_str().ok_or_else(|| SysinspectError::ModuleError(format!("{path} must be a string"))) +} + +fn expect_bool(v: &JsonValue, path: &str) -> Result { + v.as_bool().ok_or_else(|| SysinspectError::ModuleError(format!("{path} must be a boolean"))) +} + +fn expect_array<'a>(v: &'a JsonValue, path: &str) -> Result<&'a Vec, SysinspectError> { + v.as_array().ok_or_else(|| SysinspectError::ModuleError(format!("{path} must be an array"))) +} + +fn expect_object<'a>(v: &'a JsonValue, path: &str) -> Result<&'a serde_json::Map, SysinspectError> { + v.as_object().ok_or_else(|| SysinspectError::ModuleError(format!("{path} must be an object"))) +} + +fn is_empty_object(v: &JsonValue) -> bool { + v.as_object().map(|m| m.is_empty()).unwrap_or(false) +} + +fn validate_obj_list_field( + doc_obj: &serde_json::Map, field: &str, item_validator: impl Fn(usize, &JsonValue) -> Result<(), SysinspectError>, +) -> Result<(), SysinspectError> { + let Some(v) = doc_obj.get(field) else { + return Ok(()); // not there -> muted + }; + + if is_empty_object(v) { + return Ok(()); // {} -> muted + } + + let arr = expect_array(v, &format!("{}.{}", RuntimeSpec::DocumentationFunction, field))?; + for (i, item) in arr.iter().enumerate() { + item_validator(i, item)?; + } + Ok(()) +} + +pub fn validate_module_doc(doc: &JsonValue) -> Result<(), SysinspectError> { + let obj = expect_object(doc, &RuntimeSpec::DocumentationFunction.to_string())?; + let p = RuntimeModuleDocPrefix::new(&RuntimeSpec::DocumentationFunction); + + // required + let name = obj + .get(&RuntimeModuleDocumentation::Name.to_string()) + .ok_or_else(|| SysinspectError::ModuleError(format!("{} is required", p.field(&RuntimeModuleDocumentation::Name))))?; + let _ = expect_string(name, &p.field(&RuntimeModuleDocumentation::Name))?; + + let desc = obj + .get(&RuntimeModuleDocumentation::Description.to_string()) + .ok_or_else(|| SysinspectError::ModuleError(format!("{} is required", p.field(&RuntimeModuleDocumentation::Description))))?; + let _ = expect_string(desc, &p.field(&RuntimeModuleDocumentation::Description))?; + + // optional strings + if let Some(v) = obj.get(&RuntimeModuleDocumentation::Version.to_string()) { + let _ = expect_string(v, &p.field(&RuntimeModuleDocumentation::Version))?; + } + if let Some(v) = obj.get(&RuntimeModuleDocumentation::Author.to_string()) { + let _ = expect_string(v, &p.field(&RuntimeModuleDocumentation::Author))?; + } + + // arguments: array of objects (muted if missing or {}) + validate_obj_list_field(obj, &RuntimeModuleDocumentation::Arguments.to_string(), |i, item| { + let p = format!("{}.{}[{i}]", RuntimeSpec::DocumentationFunction, RuntimeModuleDocumentation::Arguments); + let aobj = expect_object(item, &p)?; + + let n = aobj + .get(&RuntimeModuleDocumentation::Name.to_string()) + .ok_or_else(|| SysinspectError::ModuleError(format!("{p}.{} is required", RuntimeModuleDocumentation::Name)))?; + let _ = expect_string(n, &format!("{p}.{}", RuntimeModuleDocumentation::Name))?; + + if let Some(t) = aobj.get(&RuntimeModuleDocumentation::Type.to_string()) { + let _ = expect_string(t, &format!("{p}.{}", RuntimeModuleDocumentation::Type))?; + } + if let Some(r) = aobj.get(&RuntimeModuleDocumentation::Required.to_string()) { + let _ = expect_bool(r, &format!("{p}.{}", RuntimeModuleDocumentation::Required))?; + } + if let Some(d) = aobj.get(&RuntimeModuleDocumentation::Description.to_string()) { + let _ = expect_string(d, &format!("{p}.{}", RuntimeModuleDocumentation::Description))?; + } + Ok(()) + })?; + + // options: array of objects (muted if missing or {}) + validate_obj_list_field(obj, &RuntimeModuleDocumentation::Options.to_string(), |i, item| { + let p = format!("{}.{}[{i}]", RuntimeSpec::DocumentationFunction, RuntimeModuleDocumentation::Options); + let oobj = expect_object(item, &p)?; + + let n = oobj + .get(&RuntimeModuleDocumentation::Name.to_string()) + .ok_or_else(|| SysinspectError::ModuleError(format!("{p}.{} is required", RuntimeModuleDocumentation::Name)))?; + let _ = expect_string(n, &format!("{p}.{}", RuntimeModuleDocumentation::Name))?; + + if let Some(d) = oobj.get(&RuntimeModuleDocumentation::Description.to_string()) { + let _ = expect_string(d, &format!("{p}.{}", RuntimeModuleDocumentation::Description))?; + } + Ok(()) + })?; + + // examples: array of objects (muted if missing or {}) + validate_obj_list_field(obj, &RuntimeModuleDocumentation::Examples.to_string(), |i, item| { + let p = format!("{}.{}[{i}]", RuntimeSpec::DocumentationFunction, RuntimeModuleDocumentation::Examples); + let eobj = expect_object(item, &p)?; + + let code = eobj + .get(&RuntimeModuleDocumentation::Code.to_string()) + .ok_or_else(|| SysinspectError::ModuleError(format!("{p}.{} is required", RuntimeModuleDocumentation::Code)))?; + let _ = expect_string(code, &format!("{p}.{}", RuntimeModuleDocumentation::Code))?; + + if let Some(d) = eobj.get(&RuntimeModuleDocumentation::Description.to_string()) { + let _ = expect_string(d, &format!("{p}.{}", RuntimeModuleDocumentation::Description))?; + } + Ok(()) + })?; + + // returns: object (optional). If present but {} -> muted too. + if let Some(ret) = obj.get(&RuntimeModuleDocumentation::Returns.to_string()) + && !is_empty_object(ret) + { + let robj = expect_object(ret, &format!("{}.{}", RuntimeSpec::DocumentationFunction, RuntimeModuleDocumentation::Returns))?; + if let Some(d) = robj.get(&RuntimeModuleDocumentation::Description.to_string()) { + let _ = expect_string( + d, + &format!( + "{}.{}.{}", + RuntimeSpec::DocumentationFunction, + RuntimeModuleDocumentation::Returns, + RuntimeModuleDocumentation::Description + ), + )?; + } + // sample: any JSON type ok + } + + Ok(()) +} diff --git a/libmodcore/src/rtspec.rs b/libmodcore/src/rtspec.rs new file mode 100644 index 00000000..2547ed20 --- /dev/null +++ b/libmodcore/src/rtspec.rs @@ -0,0 +1,114 @@ +use strum_macros::{Display, EnumString}; + +/// RuntimeParams enum +/// Holds runtime parameter names +#[derive(Debug, Display, EnumString)] +pub enum RuntimeParams { + #[strum(serialize = "rt.mod")] + ModuleName, + + #[strum(serialize = "rt.man")] + ModuleManual, + + #[strum(serialize = "rt.")] + RtPrefix, +} + +/// RuntimeSpec enum +/// Holds runtime specification field names +#[derive(Debug, Display, EnumString)] +pub enum RuntimeSpec { + #[strum(serialize = "run")] + MainEntryFunction, + + #[strum(serialize = "doc")] + DocumentationFunction, + + /// Logs section field for returning logs from module + /// within the returned data object. + #[strum(serialize = "__sysinspect-module-logs")] + LogsSectionField, + + #[strum(serialize = "data")] + DataSectionField, +} + +/// RuntimeModuleDocumentation enum +/// Holds documentation field names +#[derive(Debug, Display, EnumString)] +pub enum RuntimeModuleDocumentation { + #[strum(serialize = "name")] + Name, + + #[strum(serialize = "version")] + Version, + + #[strum(serialize = "description")] + Description, + + #[strum(serialize = "author")] + Author, + + #[strum(serialize = "license")] + License, + + #[strum(serialize = "arguments")] + Arguments, + + #[strum(serialize = "options")] + Options, + + #[strum(serialize = "examples")] + Examples, + + #[strum(serialize = "returns")] + Returns, + + #[strum(serialize = "code")] + Code, + + #[strum(serialize = "required")] + Required, + + #[strum(serialize = "type")] + Type, +} + +/// RuntimeModuleDocPrefix struct +/// Holds prefix for runtime module documentation fields +/// # Fields +/// * `prefix` - Prefix string +pub struct RuntimeModuleDocPrefix { + pub prefix: String, +} + +/// RuntimeModuleDocPrefix implementation +impl RuntimeModuleDocPrefix { + /// Create new RuntimeModuleDocPrefix + /// # Arguments + /// * `prefix` - RuntimeSpec enumeration + /// # Returns + /// RuntimeModuleDocPrefix instance + pub fn new(prefix: &RuntimeSpec) -> Self { + Self { prefix: prefix.to_string() } + } + + /// Get full field name with prefix + /// # Arguments + /// * `field` - Field enumeration + /// # Returns + /// Full field name string + pub fn field(&self, field: &RuntimeModuleDocumentation) -> String { + format!("{}.{}", self.prefix, field) + } +} + +/// Get runtime modules path +/// # Arguments +/// * `runtime_id` - Runtime identifier +/// * `sharelib` - Optional sharelib base path (without `$PATH/lib` part) +/// # Returns +/// Runtime modules path +pub fn runtime_modules_path(runtime_id: &str, sharelib: Option<&str>) -> String { + format!("/{}/lib/{runtime_id}", sharelib.unwrap_or("/usr/share/sysinspect")) +} diff --git a/libmodcore/src/runtime.rs b/libmodcore/src/runtime.rs index 15982271..8c581808 100644 --- a/libmodcore/src/runtime.rs +++ b/libmodcore/src/runtime.rs @@ -1,12 +1,15 @@ +use crate::rtspec::RuntimeParams; + use super::response::ModResponse; use indexmap::IndexMap; +use libsysinspect::cfg::mmconf::DEFAULT_MODULES_SHARELIB; use libsysinspect::util; use serde::{Deserialize, Serialize}; use std::io::Error; use std::io::{self, Read}; /// ArgValue is a type converter from input JSON to the internal types -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct ArgValue(serde_json::Value); impl ArgValue { @@ -79,26 +82,68 @@ impl ModRequest { self.quiet.unwrap_or(false).to_owned() } + pub fn options_all(&self) -> Vec { + self.options.to_owned().unwrap_or_default() + } + /// Get param options pub fn options(&self) -> Vec { - self.options.to_owned().unwrap_or_default() + let mut out = Vec::new(); + for av in self.options.to_owned().unwrap_or_default() { + if let Some(s) = av.as_string() + && !s.starts_with(&RuntimeParams::RtPrefix.to_string()) + { + out.push(av); + } + } + out + } + + /// Check if an option is present + pub fn has_option(&self, opt: &str) -> bool { + for av in self.options_all() { + if av.as_string().unwrap_or_default().eq(opt) { + return true; + } + } + false } pub fn config(&self) -> IndexMap { - self.config.clone().unwrap_or_default() + // Inject sharelib path if not defined + // Modules not supposed to take explicit care where is their shared library located, + // but simply read the configuration. For example, runtimes need to know where to find their + // modules. + let mut config = self.config.clone().unwrap_or_default(); + if config.get("path.sharelib").is_none() { + config.insert("path.sharelib".to_string(), ArgValue(serde_json::Value::String(DEFAULT_MODULES_SHARELIB.to_string()))); + } + config } - /// Get all param args - pub fn args(&self) -> IndexMap { + /// Get all param args including runtime-specific ones (those starting with "rt.") + pub fn args_all(&self) -> IndexMap { self.arguments.clone().unwrap_or_default() } + /// Get all param args without runtime-specific ones (those starting with "rt.") + pub fn args(&self) -> IndexMap { + let mut target_args = IndexMap::new(); + for (k, v) in self.arguments.clone().unwrap_or_default() { + if !k.starts_with(&RuntimeParams::RtPrefix.to_string()) { + target_args.insert(k, v); + } + } + target_args + } + /// Get arg pub fn get_arg(&self, kw: &str) -> Option { if let Some(a) = &self.arguments - && let Some(a) = a.get(kw) { - return Some(a.clone()); - }; + && let Some(a) = a.get(kw) + { + return Some(a.clone()); + }; None } diff --git a/libmodpak/Cargo.toml b/libmodpak/Cargo.toml index 068b8065..b79a25a5 100644 --- a/libmodpak/Cargo.toml +++ b/libmodpak/Cargo.toml @@ -23,3 +23,4 @@ cruet = "0.15.0" prettytable = "0.10.0" glob = "0.3.3" regex = "1.12.2" +textwrap = { version = "0.16.2", features = ["hyphenation"] } diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 80750076..10511673 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -7,11 +7,12 @@ use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, DEFAUL use libsysinspect::{SysinspectError, cfg::mmconf::DEFAULT_MODULES_DIR}; use mpk::{ModAttrs, ModPakMetadata, ModPakRepoIndex}; use once_cell::sync::Lazy; -use prettytable::Table; use prettytable::format::{FormatBuilder, LinePosition, LineSeparator}; +use prettytable::{Cell, Row, Table, format}; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::{collections::HashMap, fs, path::PathBuf}; +use textwrap::{Options, wrap}; use tokio::sync::Mutex; pub mod mpk; @@ -471,44 +472,40 @@ impl SysInspectModPak { println!("{} {}", padded.bright_yellow(), descr.white()); } } - /// Prints a table of modules with their attributes. - fn print_table(modules: &IndexMap, verbose: bool) { - let mw = modules.keys().map(|s| s.len()).max().unwrap_or(0); - let kw = "descr".len().max("type".len()); - let mut mods: Vec<_> = modules.iter().collect(); - mods.sort_by_key(|(name, _)| *name); - - for (mname, attrs) in mods { - let mut m_attrs = [("descr", attrs.descr()), ("type", attrs.mod_type())]; - m_attrs.sort_by_key(|(k, _)| *k); - if let Some((first_key, first_value)) = m_attrs.first() { - println!(" {:kw$}: {}", mname.bright_white().bold(), first_key.yellow(), first_value, mw = mw, kw = kw,); - for (k, v) in m_attrs.iter().skip(1) { - println!(" {:kw$}: {}", "", k.yellow(), v, mw = mw, kw = kw,); - } - // Print additional data, if any - if verbose { - if let Some(opts) = attrs.opts() { - println!("{}", " Options:".yellow()); - for opt in opts { - Self::print_kv(opt.name(), opt.required(), &opt.description(), 15); - } - println!() - } + fn print_table(modules: &IndexMap, _verbose: bool) { + let mut t = Table::new(); + t.set_format(*format::consts::FORMAT_CLEAN); + /* + t.set_titles(Row::new(vec![ + Cell::new(" "), + Cell::new(&"Name".bright_yellow().bold().to_string()), + Cell::new(&"Description".bright_white().bold().to_string()), + ])); + */ - if let Some(args) = attrs.args() { - println!("{}", " Arguments:".yellow()); - for arg in args { - Self::print_kv(arg.name(), arg.required(), &arg.description(), 15); - } - } - } - } else { - println!(" {mname: = modules.iter().collect(); + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + + let wrap_opts = Options::new(50); + for (modname, modattr) in entries { + let lines = wrap(modattr.descr(), &wrap_opts); + if lines.is_empty() { + t.add_row(Row::new(vec![Cell::new(" "), Cell::new(&modname.bright_yellow().to_string()), Cell::new("")])); + continue; + } + + t.add_row(Row::new(vec![Cell::new(" "), Cell::new(&modname.bright_yellow().to_string()), Cell::new(&lines[0])])); + for l in lines.iter().skip(1) { + t.add_row(Row::new(vec![Cell::new(""), Cell::new(""), Cell::new(l)])); + } + if lines.len() > 1 { + t.add_row(Row::new(vec![Cell::new(""), Cell::new(""), Cell::new("")])); } - println!(); } + + t.printstd(); } /// Lists all libraries in the repository. diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 31244b5c..9f167c3a 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -382,6 +382,11 @@ pub struct MinionConfig { #[serde(skip_serializing_if = "Option::is_none")] log_err: Option, + // Forward logs from actions and modules to the main sysinspect log + #[serde(rename = "log.forward")] + #[serde(skip_serializing_if = "Option::is_none")] + log_forward: Option, + // Pidfile #[serde(rename = "pidfile")] #[serde(skip_serializing_if = "Option::is_none")] @@ -488,6 +493,12 @@ impl MinionConfig { PathBuf::from(format!("/run/user/{}/sysminion.pid", unsafe { libc::getuid() })) } + /// Forward logs from actions and modules to the main sysinspect log + /// Default: true + pub fn forward_logs(&self) -> bool { + self.log_forward.unwrap_or(true) + } + /// Return main logfile in daemon mode pub fn logfile_std(&self) -> PathBuf { if let Some(lfn) = &self.log_main { diff --git a/libsysinspect/src/inspector.rs b/libsysinspect/src/inspector.rs index ef355ea1..cb6ae23b 100644 --- a/libsysinspect/src/inspector.rs +++ b/libsysinspect/src/inspector.rs @@ -155,7 +155,7 @@ impl SysInspectRunner { match self.action_allowed(&ac) { Ok(is_allowed) => { if is_allowed { - match ac.run() { + match ac.run(Self::minion_cfg().clone().forward_logs()) { Ok(response) => { let response = response.unwrap_or(ActionResponse::default()); self.update_cstr_eval(&response); diff --git a/libsysinspect/src/intp/actions.rs b/libsysinspect/src/intp/actions.rs index 07ebbd6e..047ac51c 100644 --- a/libsysinspect/src/intp/actions.rs +++ b/libsysinspect/src/intp/actions.rs @@ -4,7 +4,7 @@ use super::{ functions, inspector::SysInspector, }; -use crate::{SysinspectError, util::dataconv}; +use crate::{SysinspectError, logger::log_forward, util::dataconv}; use colored::Colorize; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ pub struct ModArgs { options: Option>, #[serde(alias = "args")] - arguments: Option>, + arguments: Option>, #[serde(alias = "ctx")] context: Option>, // Context variables definition for Jinja templates. Used only for model documentation. @@ -28,7 +28,7 @@ pub struct ModArgs { impl ModArgs { /// Return args - pub fn args(&self) -> IndexMap { + pub fn args(&self) -> IndexMap { if let Some(args) = &self.arguments { return args.to_owned(); } @@ -133,13 +133,60 @@ impl Action { } /// Run action - pub fn run(&self) -> Result, SysinspectError> { - if let Some(call) = &self.call { - log::debug!("Calling action {} on state {}", self.id().yellow(), call.state().yellow()); - return call.run(); + pub fn run(&self, forward_logs: bool) -> Result, SysinspectError> { + let Some(call) = &self.call else { + return Ok(None); + }; + + log::debug!("Calling action {} on state {}", self.id().yellow(), call.state().yellow()); + + let mut r_opt = call.run().map_err(|err| SysinspectError::ModelDSLError(format!("Action {} failed to run: {}", self.id(), err)))?; + + let Some(ref mut r) = r_opt else { + return Ok(r_opt); + }; + let Some(mut data) = r.response.data() else { + return Ok(r_opt); + }; + let serde_json::Value::Object(ref mut map) = data else { + r.response.set_data(data); + return Ok(r_opt); + }; + + // XXX: Logs entry key must be picked from RuntimeSpec::LogsSectionField, but + // currently it is a cycle dependency. The rtspec must be split into a separate crate. + // + // At some point in future... + // + if let Some(logs_val) = map.remove("__sysinspect-module-logs") { + if forward_logs { + Self::forward_logs(&logs_val); + } else { + map.insert("logs".to_string(), logs_val); + } } - Ok(None) + r.response.set_data(data); + + Ok(r_opt) + } + + /// Forward logs value (string/array/anything) to internal logger. + fn forward_logs(logs_val: &serde_json::Value) { + match logs_val { + serde_json::Value::Array(items) => { + for item in items { + if let Some(line) = item.as_str() { + log_forward(line); + } else { + log_forward(&dataconv::as_str(Some(item.clone()))); + } + } + } + other => { + log_forward(&dataconv::as_str(Some(other.clone()))); + } + } } fn resolve_claims( @@ -147,7 +194,7 @@ impl Action { ) -> Result, SysinspectError> { let mut out: Vec = Vec::default(); for mut expr in v_expr { - if let Some(modfunc) = functions::is_function(&dataconv::to_string(expr.get_op()).unwrap_or_default()).ok().flatten() { + if let Some(modfunc) = functions::is_function(&expr.get_op().unwrap_or(Value::String("".to_string()))).ok().flatten() { match inspector.call_function(Some(eid), &state, &modfunc) { Ok(Some(v)) => expr.set_active_op(v)?, Ok(_) => {} @@ -223,8 +270,7 @@ impl Action { ))); } Ok(Some(v)) => { - // XXX: Passing args to the modcall are for now always strings - arg = dataconv::to_string(Some(v)).unwrap_or_default(); + arg = v; } Err(err) => return Err(err), } diff --git a/libsysinspect/src/intp/actproc/modfinder.rs b/libsysinspect/src/intp/actproc/modfinder.rs index b40390f4..021b5dd1 100644 --- a/libsysinspect/src/intp/actproc/modfinder.rs +++ b/libsysinspect/src/intp/actproc/modfinder.rs @@ -60,7 +60,7 @@ pub struct ModCall { constraints: Vec, // Module params - args: IndexMap, // XXX: Should be String/Value, not String/String! + args: IndexMap, opts: Vec, conditions: IndexMap, } @@ -97,7 +97,7 @@ impl ModCall { } /// Add a pair of kwargs - pub fn add_kwargs(&mut self, kw: String, arg: String) -> &mut Self { + pub fn add_kwargs(&mut self, kw: String, arg: Value) -> &mut Self { self.args.insert(kw, arg); self } diff --git a/libsysinspect/src/intp/actproc/response.rs b/libsysinspect/src/intp/actproc/response.rs index 99bae8c0..7ef8a9a8 100644 --- a/libsysinspect/src/intp/actproc/response.rs +++ b/libsysinspect/src/intp/actproc/response.rs @@ -151,6 +151,11 @@ impl ActionModResponse { self.data.to_owned() } + /// Set data payload + pub fn set_data(&mut self, v: Value) { + self.data = Some(v); + } + /// Add or merge a key-value pair into the data object. pub fn add_data(&mut self, key: &str, v: Value) { match &mut self.data { diff --git a/libsysinspect/src/intp/functions.rs b/libsysinspect/src/intp/functions.rs index 8a6d82fd..b1e8efa6 100644 --- a/libsysinspect/src/intp/functions.rs +++ b/libsysinspect/src/intp/functions.rs @@ -102,7 +102,12 @@ where } /// Detect if an argument is a function -pub fn is_function(arg: &str) -> Result, SysinspectError> { +pub fn is_function(arg: &YamlValue) -> Result, SysinspectError> { + let arg = match arg.as_str() { + Some(s) => s, + None => return Ok(None), + }; + if !arg.contains("(") || !arg.ends_with(")") { return Ok(None); } diff --git a/libsysinspect/src/logger.rs b/libsysinspect/src/logger.rs index 4afb9161..0b45030d 100644 --- a/libsysinspect/src/logger.rs +++ b/libsysinspect/src/logger.rs @@ -69,3 +69,55 @@ impl log::Log for MemoryLogger { fn flush(&self) {} } + +/// Forward a log line to the internal logger +/// This is used to forward logs from subprocesses (modules, typically) or external tools. +/// A module would return a structure with "data" and "logs" fields, where "logs" is a list of log lines +/// in the expected format. This function would be called for each log line to forward it to the main logger. +/// +/// IMPORTANT: The formatting must be equal! +/// +/// Expected wire format: +/// ``` +/// "[..timestamp..] - LEVEL: [highlight] message" +/// ``` +/// +/// Example log line: +/// ``` +/// 2024-10-05 14:23:01 - INFO: [ModuleXYZ] This is a log message +/// ``` +pub fn log_forward(line: &str) { + // timestamp + let after_ts = match line.split_once(" - ") { + Some((_ts, rest)) => rest, + None => line, + }; + + // level + let (level, msg) = match after_ts.split_once(':') { + Some((lvl, rest)) => (lvl.trim(), rest.trim()), + None => ("INFO", after_ts.trim()), + }; + + // Highlight leading [xxx] + let painted_msg = if let Some(rest) = msg.strip_prefix('[') { + if let Some((tag, tail)) = rest.split_once(']') { + let tag = format!("[{}]", tag).bright_magenta(); + let tail = tail.trim_start(); // eat leading space + format!("{tag} {tail}") + } else { + msg.to_string() + } + } else { + msg.to_string() + }; + + match level { + "ERROR" => log::error!("{painted_msg}"), + "WARN" | "WARNING" => log::warn!("{painted_msg}"), + "DEBUG" => log::debug!("{painted_msg}"), + "TRACE" => log::trace!("{painted_msg}"), + "INFO" => log::info!("{painted_msg}"), + _ => log::info!("{painted_msg}"), + } +} diff --git a/libsysinspect/src/util/mod.rs b/libsysinspect/src/util/mod.rs index 8e3ffb78..17e9f557 100644 --- a/libsysinspect/src/util/mod.rs +++ b/libsysinspect/src/util/mod.rs @@ -1,6 +1,7 @@ pub mod dataconv; pub mod iofs; pub mod sys; +pub mod tty; use crate::SysinspectError; use std::{fs, io, path::PathBuf}; diff --git a/libsysinspect/src/util/tty.rs b/libsysinspect/src/util/tty.rs new file mode 100644 index 00000000..218ec0ad --- /dev/null +++ b/libsysinspect/src/util/tty.rs @@ -0,0 +1,194 @@ +/// Renders simple markup to ANSI escape codes for terminal output. +/// Markup syntax: +/// - `[fg:bg:attrs]` where: +/// - `fg` is a single character for foreground color (e.g. 'r' for red) +/// - `bg` is a single character for background color (e.g. 'b' for blue) +/// - `attrs` is a string of characters for attributes (e.g. 'bu' for bold and underline) +/// - `[N]` resets all attributes +/// - Unknown tags are rendered literally +/// # Supported colors: +/// - k: black +/// - r: red +/// - g: green +/// - y: yellow +/// - b: blue +/// - m: magenta +/// - c: cyan +/// - w: white +/// - K: bright black (gray) +/// - R: bright red +/// - G: bright green +/// - Y: bright yellow +/// - B: bright blue +/// - M: bright magenta +/// - C: bright cyan +/// - W: bright white +/// # Supported attributes: +/// - b: bold +/// - d: dim +/// - u: underline +/// - i: inverse +/// - s: strikethrough +/// # Arguments +/// * `input` - Input string with markup +/// # Returns +/// * `String` - Output string with ANSI escape codes +/// # Example +/// ```no_run +/// let rendered = render_markup("This is [r::b]red text on default background[N] and this is [::bu]bold underlined text[N]."); +/// println!("{}", rendered); +/// ``` +pub fn render_markup(input: &str) -> String { + fn fg_code(c: char) -> Option<&'static str> { + Some(match c { + 'k' => "\x1b[30m", + 'r' => "\x1b[31m", + 'g' => "\x1b[32m", + 'y' => "\x1b[33m", + 'b' => "\x1b[34m", + 'm' => "\x1b[35m", + 'c' => "\x1b[36m", + 'w' => "\x1b[37m", + 'K' => "\x1b[90m", + 'R' => "\x1b[91m", + 'G' => "\x1b[92m", + 'Y' => "\x1b[93m", + 'B' => "\x1b[94m", + 'M' => "\x1b[95m", + 'C' => "\x1b[96m", + 'W' => "\x1b[97m", + _ => return None, + }) + } + + fn bg_code(c: char) -> Option<&'static str> { + Some(match c { + 'k' => "\x1b[40m", + 'r' => "\x1b[41m", + 'g' => "\x1b[42m", + 'y' => "\x1b[43m", + 'b' => "\x1b[44m", + 'm' => "\x1b[45m", + 'c' => "\x1b[46m", + 'w' => "\x1b[47m", + 'K' => "\x1b[100m", + 'R' => "\x1b[101m", + 'G' => "\x1b[102m", + 'Y' => "\x1b[103m", + 'B' => "\x1b[104m", + 'M' => "\x1b[105m", + 'C' => "\x1b[106m", + 'W' => "\x1b[107m", + _ => return None, + }) + } + + fn attr_code(c: char) -> Option<&'static str> { + Some(match c { + 'b' => "\x1b[1m", // bold + 'd' => "\x1b[2m", // dim + 'u' => "\x1b[4m", // underline + 'i' => "\x1b[7m", // inverse + 's' => "\x1b[9m", // strikethrough + _ => return None, + }) + } + + let mut out = String::with_capacity(input.len() + 16); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '[' { + out.push(ch); + continue; + } + + // Collect tag until ']' + let mut tag = String::new(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == ']' { + break; + } + tag.push(c); + } + + // If we didn't end with ']', treat as literal + if !tag.ends_with([]) && !input.contains(']') { + out.push('['); + out.push_str(&tag); + continue; + } + + // Special reset tag + if tag == "N" { + out.push_str("\x1b[0m"); + continue; + } + + // Parse fg:bg:attrs + // - fg and bg are optional (empty allowed) + // - attrs can be multiple letters like "bu" + let mut parts = tag.splitn(3, ':'); + let fg = parts.next().unwrap_or(""); + let bg = parts.next().unwrap_or(""); + let attrs = parts.next().unwrap_or(""); + + // If no ':' at all, treat literal (avoid eating user's text) + if !tag.contains(':') { + out.push('['); + out.push_str(&tag); + out.push(']'); + continue; + } + + // Apply: attributes first or last doesn't really matter with ANSI, + // but we’ll do attrs, then fg, then bg. + let mut applied = false; + + for a in attrs.chars() { + if let Some(code) = attr_code(a) { + out.push_str(code); + applied = true; + } + } + + if let Some(c) = fg.chars().next() + && let Some(code) = fg_code(c) + { + out.push_str(code); + applied = true; + } + + if let Some(c) = bg.chars().next() + && let Some(code) = bg_code(c) + { + out.push_str(code); + applied = true; + } + + // If nothing applied (unknown tag), render literally + if !applied { + out.push('['); + out.push_str(&tag); + out.push(']'); + } + } + + out +} + +/// Indent each line of the given string with the specified prefix. +/// # Arguments +/// * `s` - Input string to indent +/// * `prefix` - Prefix string to add to each line +/// # Returns +/// * `String` - Indented string +/// # Example +/// ```no_run +/// let indented = indent_block("Line 1\nLine 2\nLine 3", ">> "); +/// println!("{}", indented); +/// ``` +pub fn indent_block(s: &str, prefix: &str) -> String { + s.lines().map(|line| if line.is_empty() { prefix.to_string() } else { format!("{prefix}{line}") }).collect::>().join("\n") +} diff --git a/modules/runtime/README.txt b/modules/runtime/README.txt new file mode 100644 index 00000000..3537ebbb --- /dev/null +++ b/modules/runtime/README.txt @@ -0,0 +1,13 @@ +Good news: + Runtimes are just a regular standalone modules + +Each runtime has just as same interaction as a regular module, +except it has additional information, such as: + +1. id (i.e. string of runtime identification: "wasm", "lua", "python" etc) +2. Additional targeting. A regular module has just opts and args, but a runtime + must additionally know what its module is targeted + +Different news: + Runtimes need extra management for their modules, + so package manager must take care of it. diff --git a/modules/runtime/lua-runtime/Cargo.toml b/modules/runtime/lua-runtime/Cargo.toml new file mode 100644 index 00000000..bfc0386e --- /dev/null +++ b/modules/runtime/lua-runtime/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lua-runtime" +version = "0.1.0" +edition = "2024" + +[dependencies] +libsysinspect = { path = "../../../libsysinspect" } +libmodcore = { path = "../../../libmodcore" } +serde = "1.0.228" +serde_json = "1.0.149" +serde_yaml = "0.9.34" +mlua = { version = "0.11.5", features = ["lua54", "serde", "serialize", "vendored"] } +anyhow = "1.0.100" +thiserror = "2.0.18" +clap = "4.5.54" +log = "0.4.29" +chrono = "0.4.43" diff --git a/modules/runtime/lua-runtime/examples/README.txt b/modules/runtime/lua-runtime/examples/README.txt new file mode 100644 index 00000000..46dcb72c --- /dev/null +++ b/modules/runtime/lua-runtime/examples/README.txt @@ -0,0 +1,123 @@ +Welcome! +These are expanded examples of to let you feel more comfortable with Lua +hacking (if that's the word). + +To use these, you have to compile and install Lua runtime. So typically +if you build the whole thing using "make", you will have in your +target//runtime/lua-runtime binary. Then you have to +install it, by issuing this command: + +1. sysinspect module -A --path /to/your/target/release/runtime/lua-runtime --name runtime.lua-runtime --descr "Lua runtime" + + This will put Lua runtime into your package manager repository on + SysMaster side. + +2. Sync your cluster: + + sysinspect --sync + + +You're good to go! Now you need to install these modules. Current implementation +allows you to install Lua modules as a library to "lua-runtime" module. + +To install these into your environment, do the following (assuming you +are literally HERE in the current directory): + +1. Important to add $PATH_TO_HERE/lib (or just ./lib). This way the + entire structure inside "lib" will be preserved (and this is the + most important step): + + sysinspect module -A --path ./lib -l + +2. Sync the cluster by issuing --sync: + + sysinspect --sync + +3. You can verify if that landed correctly: + + sysinslect module -Ll + +You should see something like this (among other stuff, if any): + + Type Name OS Arch SHA256 + ────────────────────────────────────────────────────────────────────────── + script runtime/lua54/caller.lua Any noarch 7aff...d8c5 + script runtime/lua54/hello.lua Any noarch 22ce...f2e1 + script runtime/lua54/reader.lua Any noarch 8ce3...0135 + script runtime/lua54/site-lua/mathx/extra.lua Any noarch 92ce...79e3 + script runtime/lua54/site-lua/mathx/init.lua Any noarch f636...f314 + + +4. To run them, add some caller to your model, something like: +------------------------------ +entities: + - foo + +actions: + my-example: + descr: Call some Lua stuff + module: runtime.lua-runtime + bind: + - foo + state: + $: + opts: + args: + mod: reader +------------------------------ + +This will call "reader.lua" module. It supposed to read your /etc/os-release +and extract VERSION tag, returning it. To execute that, call this: + + sysinspect yourmodel/foo 'yourminion' + +...where "yourmodel" is the model you use and "yourminion" is the hostname. +Or use "*" to wakeup the entire cluster. :-) + + +Below are modules description: + +hello.lua +========= + + 1. Load an extra package, made of two files + 2. Document your whole program and return its manpage + 3. Calculate something and return the result + + +caller.lua +========== + + 1. Execs a shell command + 2. Returns the output as data + + +reader.lua +========== + + 1. Reads a file /etc/os-release + 2. Finds "VERSION" + 3. Returns the result as data + +Call example: + +$ echo '{"opts":["lines"], "args":{"mod": "caller", "dir": "."}}' | ../../../../target/debug/runtime/lua-runtime | jq +{ + "retcode": 0, + "message": "Called Lua module successfully.", + "data": { + "changed": true, + "command": "ls -lah . 2>&1", + "exit_code": 0, + "output": [ + "total 28K", + "drwxr-xr-x 3 isbm user 4,0K Jan 21 19:28 .", + "drwxr-xr-x 4 isbm user 4,0K Jan 21 17:49 ..", + "-rw-r--r-- 1 isbm user 1,9K Jan 21 19:38 caller.lua", + "-rw-r--r-- 1 isbm user 1,2K Jan 21 19:34 hello.lua", + "drwxr-xr-x 3 isbm user 4,0K Jan 21 19:20 lib", + "-rw-r--r-- 1 isbm user 1,2K Jan 21 19:34 reader.lua", + "-rw-r--r-- 1 isbm user 442 Jan 21 19:28 README.txt" + ] + } +} diff --git a/modules/runtime/lua-runtime/examples/lib/runtime/lua54/caller.lua b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/caller.lua new file mode 100644 index 00000000..796cd863 --- /dev/null +++ b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/caller.lua @@ -0,0 +1,96 @@ +local M = {} + +M.doc = { + name = "caller", + version = "0.1.2", + author = "Gru", + description = "Executes `ls -lah` on a given directory and returns the output.", + + arguments = { + { + name = "dir", + type = "string", + required = true, + description = "Directory path to list" + } + }, + + options = { + { + name = "lines", + description = "Split stdout into an array of lines" + } + }, + + examples = { + { + description = "List /etc as raw output", + code = [[ +{ "args": { "mod": "caller", "dir": "/etc" } } + ]] + }, + { + description = "List /etc as lines", + code = [[ +{ "args": { "mod": "caller", "dir": "/etc" }, "opts": ["lines"] } + ]] + } + }, + + returns = { + description = "Returns stdout of `ls -lah `", + sample = { + output = { "total 4.0K", "-rw-r--r-- 1 root root ..." } + } + } +} + +--- Executes `ls -lah` on the provided directory +-- @param req table SysInspect request object +-- @return table data payload +function M.run(req) + local args = req.args or {} + local opts = req.opts or {} + + local dir = args.dir + if not dir or dir == "" then + error("argument 'dir' is required") + end + + -- check opts array + local line_split = false + for _, opt in ipairs(opts) do + if opt == "lines" then + line_split = true + break + end + end + + local cmd = "ls -lah " .. dir .. " 2>&1" + + local p = io.popen(cmd, "r") + if not p then + error("failed to execute command") + end + + local output = p:read("*a") + local ok, _, exit_code = p:close() + + local out + if line_split then + out = {} + for line in output:gmatch("([^\n]+)") do + table.insert(out, line) + end + else + out = output + end + + return { + command = cmd, + exit_code = exit_code or 0, + output = out + } +end + +return M diff --git a/modules/runtime/lua-runtime/examples/lib/runtime/lua54/hello.lua b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/hello.lua new file mode 100644 index 00000000..cc480f35 --- /dev/null +++ b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/hello.lua @@ -0,0 +1,49 @@ +local mathx = require("mathx") -- from $PATH/lib/mathx/init.lua +local extra = require("mathx.extra") -- from $PATH/lib/mathx/extra.lua +local M = {} + +-- Module documentation +M.doc = { + name = "hello", + version = "0.1.0", + author = "Bo Maryniuk", + description = "Adds two numbers.", + + -- Add name and description + options = {}, + + -- Define arguments + arguments = { + { name = "a", type = "number", required = true, description = "First number" }, + { name = "b", type = "number", required = true, description = "Second number" }, + }, + + -- Provide examples + examples = { + { + description = "Add 1 and 2", + code = [[ +{ "args": { "mod": "test", "a": 1, "b": 2 } } + ]] + } + }, + + -- Define return values + returns = { + description = "Returns {sum=}", + sample = { sum = 3 } + } +} + +--- Main function +-- @param req table Request object containing arguments +-- @return table Result containing the sum of a and b +function M.run(req) + local a = (req.args and req.args.a) or 0 + local b = (req.args and req.args.b) or 0 + + return { sum = mathx.add(a, extra.mul(a, b)) } +end + +-- Return the module +return M diff --git a/modules/runtime/lua-runtime/examples/lib/runtime/lua54/reader.lua b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/reader.lua new file mode 100644 index 00000000..d8f35f58 --- /dev/null +++ b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/reader.lua @@ -0,0 +1,63 @@ +local M = {} + +-- Module documentation +M.doc = { + name = "reader", + version = "0.1.0", + author = "Bo Maryniuk", + description = "Reads /etc/os-release and returns VERSION.", + arguments = {}, + examples = { + { + description = "Read OS version", + code = [[ +{ "args": { "mod": "reader" } } + ]] + } + }, + returns = { + description = "Returns detected OS version", + sample = { version = "12 (bookworm)" } + } +} + +--- Function to read /etc/os-release and extract VERSION +-- @return string|nil VERSION value or nil if not found +local function read_os_release() + local f, err = io.open("/etc/os-release", "r") + if not f then + error("failed to open /etc/os-release: " .. tostring(err)) + end + + local version = nil + for line in f:lines() do + local v = line:match('^VERSION="?([^"]+)"?') + if v then + version = v + break + end + end + + f:close() + return version +end + +--- Main function +-- @param _req table Request object (not used) +-- @return table Result containing the OS version +function M.run(_req) + local version = read_os_release() + + if not version then + -- "log" is already preinstalled from SysInspect environment automatically + log.error("VERSION not found in /etc/os-release") + else + log.info("Detected OS VERSION: " .. version) + end + + return { + version = version + } +end + +return M diff --git a/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/extra.lua b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/extra.lua new file mode 100644 index 00000000..5fc54095 --- /dev/null +++ b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/extra.lua @@ -0,0 +1,3 @@ +return { + mul = function(a, b) return a * b end +} diff --git a/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/init.lua b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/init.lua new file mode 100644 index 00000000..b35d26a2 --- /dev/null +++ b/modules/runtime/lua-runtime/examples/lib/runtime/lua54/site-lua/mathx/init.lua @@ -0,0 +1,7 @@ +local M = {} + +function M.add(a, b) + return a + b +end + +return M diff --git a/modules/runtime/lua-runtime/src/lrt.rs b/modules/runtime/lua-runtime/src/lrt.rs new file mode 100644 index 00000000..5ed97b34 --- /dev/null +++ b/modules/runtime/lua-runtime/src/lrt.rs @@ -0,0 +1,344 @@ +use libmodcore::{rtdocschema::validate_module_doc, rtspec::RuntimeSpec}; +use mlua::{Function, Lua, LuaSerdeExt, Table, Value as LuaValue, Variadic}; +use serde_json::Value as JsonValue; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +#[derive(thiserror::Error, Debug)] +pub enum LuaRuntimeError { + #[error("lua error: {0}")] + Lua(#[from] mlua::Error), + + #[error("sysinspect error: {0}")] + Sysinspect(#[from] libsysinspect::SysinspectError), + + #[error("failed to read lua file '{path}': {source}")] + ReadFile { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("{msg}: {source}")] + Context { + msg: String, + #[source] + source: Box, + }, +} + +pub type Result = std::result::Result; + +pub struct LuaRuntime { + lua: Lua, + scripts_dir: PathBuf, + logs: Arc>>, + modulename: Arc>, +} + +impl LuaRuntime { + /// Create a new LuaRuntime instance + /// # Arguments + /// * `scripts_dir` - Path to the directory containing Lua scripts + /// # Returns + /// * `Result` - Result containing the LuaRuntime instance or an error + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// ``` + pub fn new(sharelib_root: PathBuf, enable_native: bool) -> Result { + let lua = Lua::new(); + + // Runtime configuration + let lib_dir = sharelib_root.join("lib/runtime/lua54/site-lua"); + let globals = lua.globals(); + let package: mlua::Table = globals.get("package")?; + + // Configure native library loader + if !enable_native { + package.set("cpath", "")?; + } + + let mut path = String::new(); + path.push_str(&LuaRuntime::path_fragment(&sharelib_root.join("lib/runtime/lua54"))); + path.push(';'); + path.push_str(&LuaRuntime::path_fragment(&lib_dir)); + + package.set("path", path)?; + + let rt = Self { + lua, + scripts_dir: sharelib_root.join("lib/runtime/lua54"), + logs: Arc::new(Mutex::new(Vec::new())), + modulename: Arc::new(Mutex::new("Lua module".into())), + }; + rt.set_logger()?; + + Ok(rt) + } + + // Get scripts path fragment for Lua package.path + pub fn get_scripts_dir(&self) -> &Path { + &self.scripts_dir + } + + fn join_vals(vals: Variadic) -> String { + vals.into_iter().map(Self::v2s).collect::>().join(" ") + } + + fn v2s(v: LuaValue) -> String { + match v { + LuaValue::Nil => "nil".to_string(), + LuaValue::Boolean(b) => b.to_string(), + LuaValue::Integer(i) => i.to_string(), + LuaValue::Number(n) => n.to_string(), + LuaValue::String(s) => s.to_string_lossy().to_string(), + // Keep it simple: don't try to serialize tables here. + other => format!("", other.type_name()), + } + } + + /// Set up logging functions in Lua environment + /// # Returns + /// * `mlua::Result<()>` - Result of the operation + fn set_logger(&self) -> mlua::Result<()> { + fn push_line(logs: &Arc>>, current: &Arc>, level: &'static str, msg: String) { + let module = current.lock().map(|s| s.clone()).unwrap_or_else(|_| "Lua".into()); + let ts = chrono::Local::now().format("%d/%m/%Y %H:%M:%S"); + let line = format!("[{ts}] - {level}: [{module}] {msg}"); + if let Ok(mut g) = logs.lock() { + g.push(line); + } + } + + let globals = self.lua.globals(); + let logtbl: Table = self.lua.create_table()?; + + // error(...) + { + let logs = self.logs.clone(); + let m = self.modulename.clone(); + logtbl.set( + "error", + self.lua.create_function(move |_, vals: Variadic| { + push_line(&logs, &m, "ERROR", LuaRuntime::join_vals(vals)); + Ok(()) + })?, + )?; + } + + // warn(...) + { + let logs = self.logs.clone(); + let m = self.modulename.clone(); + logtbl.set( + "warn", + self.lua.create_function(move |_, vals: Variadic| { + push_line(&logs, &m, "WARN", LuaRuntime::join_vals(vals)); + Ok(()) + })?, + )?; + } + + // info(...) + { + let logs = self.logs.clone(); + let m = self.modulename.clone(); + logtbl.set( + "info", + self.lua.create_function(move |_, vals: Variadic| { + push_line(&logs, &m, "INFO", LuaRuntime::join_vals(vals)); + Ok(()) + })?, + )?; + } + + // debug(...) + { + let logs = self.logs.clone(); + let m = self.modulename.clone(); + logtbl.set( + "debug", + self.lua.create_function(move |_, vals: Variadic| { + push_line(&logs, &m, "DEBUG", LuaRuntime::join_vals(vals)); + Ok(()) + })?, + )?; + } + + globals.set("log", logtbl)?; + Ok(()) + } + + // Lua package.path uses ; separated patterns with ? + // Typical: /path/?.lua;/path/?/init.lua + fn path_fragment(dir: &Path) -> String { + let d = dir.to_string_lossy(); + format!("{d}/?.lua;{d}/?/init.lua") + } + + /// Execute Lua code string + /// # Arguments + /// * `code` - Lua code string + /// # Returns + /// * `Result<()>` - Result of the execution + /// # Errors + /// * `LuaRuntimeError` - If the execution fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// rt.exec_str(r#"print("Hello, Lua!")"#)?; + /// ``` + pub fn exec_str(&self, code: &str) -> Result<()> { + self.lua.load(code).exec()?; + Ok(()) + } + + /// Call Lua module's run(req) function + /// # Arguments + /// * `code` - Lua module code string + /// * `req` - JSON request value + /// # Returns + /// * `JsonValue` - JSON response value + /// # Errors + /// * `LuaRuntimeError` - If the module call fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// let resp = rt.call_module(r#"return { run = function(req) return { message = "Hello, " .. req.name } end }"#, &serde_json::json!({ "name": "World" }))?; + /// println!("{}", serde_json::to_string_pretty(&resp).unwrap()); + /// ``` + pub fn call_module(&self, modname: &str, code: &str, req: &JsonValue, with_logs: bool) -> Result { + // clear per-call log buffer, in case lrt is called multiple times + if let Ok(mut g) = self.logs.lock() { + g.clear(); + } + + // Set module name for logging + if let Ok(mut m) = self.modulename.lock() { + *m = modname.to_string(); + } + + // Tell Lua module its name + self.lua.globals().set("__module_name", modname)?; + + let module: Table = self.lua.load(code).eval()?; + let run: Function = + module.get(RuntimeSpec::MainEntryFunction.to_string()).map_err(|_| mlua::Error::runtime("Lua module must export run(req) function!"))?; + + let lua_req = self.lua.to_value(req)?; + let result: LuaValue = run.call(lua_req)?; + + // Parse module return into JSON "data" + let data: JsonValue = match result { + LuaValue::Table(t) => self.lua.from_value(LuaValue::Table(t))?, + LuaValue::String(s) => serde_json::from_str(&s.to_str()?).map_err(|e| mlua::Error::runtime(e.to_string()))?, + _ => return Err(mlua::Error::runtime("Lua run() must return table or JSON string").into()), + }; + + // Grab buffered logs + let logs = if with_logs { if let Ok(g) = self.logs.lock() { g.clone() } else { Vec::new() } } else { Vec::new() }; + + // Return { data, logs } + Ok(serde_json::json!({ + RuntimeSpec::DataSectionField.to_string(): data, + RuntimeSpec::LogsSectionField.to_string(): logs + })) + } + + /// Get module documentation from Lua code + /// # Arguments + /// * `code` - Lua module code string + /// # Returns + /// * `JsonValue` - Module documentation as JSON value + /// # Errors + /// * `LuaRuntimeError` - If the documentation retrieval or validation fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// let doc = rt.module_doc(r#"return { documentation = { name = "My Module", description = "This is a test module." } }"#)?; + /// println!("{}", serde_json::to_string_pretty(&doc).unwrap()); + /// ``` + pub fn module_doc(&self, code: &str) -> Result { + let module: Table = self.lua.load(code).eval()?; + let doc: LuaValue = module.get(RuntimeSpec::DocumentationFunction.to_string())?; + + let json = match doc { + LuaValue::Table(t) => self.lua.from_value(LuaValue::Table(t))?, + LuaValue::Nil => serde_json::json!({}), + _ => return Err(mlua::Error::runtime("module doc must be a table").into()), + }; + + validate_module_doc(&json)?; + + Ok(json) + } + + pub fn exec_file(&self, path: &str) -> Result<()> { + let code = std::fs::read_to_string(path).map_err(|e| LuaRuntimeError::ReadFile { path: path.to_string(), source: e })?; + + self.exec_str(&code).map_err(|e| LuaRuntimeError::Context { msg: format!("lua exec_file failed: {path}"), source: Box::new(e) }) + } + + /// Call a Lua function with arguments + /// # Arguments + /// * `name` - Function name + /// * `args` - Function arguments + /// # Returns + /// * `R` - Return type of the function + /// # Errors + /// * `LuaRuntimeError` - If the function call fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// rt.exec_str(r#"function add(a, b) return a + b end"#)?; + /// let result: i64 = rt.call_fn("add", (2, 3))?; + /// assert_eq!(result, 5); + /// ``` + pub fn call_fn(&self, name: &str, args: impl mlua::IntoLuaMulti) -> Result { + let globals = self.lua.globals(); + let f: mlua::Function = globals.get(name)?; + Ok(f.call(args)?) + } + + /// Set a global variable in Lua + /// # Arguments + /// * `key` - Variable name + /// * `val` - Variable value + /// # Returns + /// * `Result<()>` - Result of the operation + /// # Errors + /// * `LuaRuntimeError` - If setting the global variable fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// rt.set_global("my_var", 42)?; + /// let value: i64 = rt.get_global("my_var")?; + /// assert_eq!(value, 42); + /// ``` + pub fn set_global(&self, key: &str, val: impl mlua::IntoLua) -> Result<()> { + let v = val.into_lua(&self.lua)?; + self.lua.globals().set(key, v)?; + Ok(()) + } + + /// Get a global variable from Lua + /// # Arguments + /// * `key` - Variable name + /// # Returns + /// * `Result` - Value of the variable + /// # Errors + /// * `LuaRuntimeError` - If getting the global variable fails + /// # Example + /// ```no_run + /// let rt = LuaRuntime::new(PathBuf::from("scripts"))?; + /// rt.set_global("my_var", 42)?; + /// let value: i64 = rt.get_global("my_var")?; + /// assert_eq!(value, 42); + /// ``` + pub fn get_global(&self, key: &str) -> Result { + Ok(self.lua.globals().get(key)?) + } +} diff --git a/modules/runtime/lua-runtime/src/main.rs b/modules/runtime/lua-runtime/src/main.rs new file mode 100644 index 00000000..a988d644 --- /dev/null +++ b/modules/runtime/lua-runtime/src/main.rs @@ -0,0 +1,161 @@ +mod lrt; +use crate::lrt::{LuaRuntime, LuaRuntimeError}; +use clap::Parser; +use libmodcore::{ + init_mod_doc, + manrndr::print_mod_manual, + modcli::ModuleCli, + modinit::ModInterface, + response::ModResponse, + rtspec::RuntimeParams, + runtime::{ModRequest, get_call_args, send_call_response}, +}; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +/// Read Lua module code from file +fn read_module_code(modname: &str, scripts_dir: &Path) -> std::io::Result { + let path = scripts_dir.join(format!("{}.lua", modname)); + std::fs::read_to_string(path) +} + +/// List available Lua modules in the scripts directory +fn list_lua_modules(scripts_dir: &Path) -> Vec { + let mut modules = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(scripts_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && let Some(ext) = path.extension() + && ext == "lua" + && let Some(stem) = path.file_stem() + && let Some(stem_str) = stem.to_str() + { + modules.push(stem_str.to_string()); + } + } + } + + modules +} + +/// Get module documentation from Lua runtime +fn module_doc_help(cli: &ModuleCli, modname: &str) -> Result { + let rt = match LuaRuntime::new(PathBuf::from(cli.get_sharelib()), false) { + Ok(rt) => rt, + Err(err) => { + eprintln!("Failed to create Lua runtime: {}", err); + return Err(err); + } + }; + + rt.module_doc(&read_module_code(modname, rt.get_scripts_dir()).unwrap_or_default()) +} + +/// Run the Lua runtime with the provided request. +fn call_runtime(cli: &ModuleCli, rq: &ModRequest) -> ModResponse { + let mut resp = ModResponse::new_cm(); + + // Get sharelib path from passed config or override from CLI or default + let sharelib = rq.config().get("path.sharelib").and_then(|v| v.as_string()).unwrap_or(cli.get_sharelib()); + let rt = match LuaRuntime::new(PathBuf::from(&sharelib), rq.has_option(&format!("{}{}", RuntimeParams::RtPrefix, "native"))) { + Ok(rt) => rt, + Err(err) => { + resp.set_message(&format!("Failed to create Lua runtime: {}", err)); + return resp; + } + }; + + // list modules only? + for opt in rq.options_all() { + if opt.as_string().unwrap_or_default().eq(&format!("{}{}", RuntimeParams::RtPrefix, "list")) { + let modules = list_lua_modules(rt.get_scripts_dir()); + match resp.set_data(serde_json::json!({ "modules": modules })) { + Ok(_) => {} + Err(err) => { + resp.set_message(&format!("Failed to set response data: {}", err)); + return resp; + } + } + resp.set_retcode(0); + resp.set_message("Listed available Lua modules successfully."); + return resp; + } + } + + // Call the module + let modpath = match rq.args_all().get(&RuntimeParams::ModuleName.to_string()) { + Some(v) => v.as_string().unwrap_or_default(), + None => String::new(), + }; + + if modpath.is_empty() { + let mut resp = ModResponse::new_cm(); + resp.set_message(&format!("No module name provided. Set '{}' argument properly.", RuntimeParams::ModuleName)); + return resp; + } + match rt.call_module( + &modpath, + &read_module_code(&modpath, rt.get_scripts_dir()).unwrap_or_default(), + &serde_json::json!({"args": rq.args(), "config": rq.config(), "opts": rq.options(), "ext": rq.ext()}), + rq.has_option(&format!("{}{}", RuntimeParams::RtPrefix, "logs")), + ) { + Ok(data) => { + match resp.set_data(data) { + Ok(_) => { + let _ = resp.cm_set_changed(true); + } + Err(err) => { + resp.set_message(&format!("Failed to set response data: {}", err)); + return resp; + } + } + resp.set_retcode(0); + resp.set_message("Called Lua module successfully."); + } + Err(err) => { + resp.set_message(&format!("Failed to execute Lua code: {}. Scripts directory: {}", err, rt.get_scripts_dir().display())); + return resp; + } + }; + + resp +} + +/// Main entry point +fn main() { + let mod_doc = init_mod_doc!(ModInterface); + let cli = ModuleCli::parse(); + + // CLI calls from the terminal directly + if cli.is_manual() { + print!("{}", mod_doc.help()); + return; + } else if !cli.get_help_on().is_empty() { + match module_doc_help(&cli, &cli.get_help_on()) { + Ok(doc) => { + print_mod_manual(doc); + } + Err(err) => { + eprintln!("Failed to get module documentation: {}", err); + } + } + return; + } else if cli.is_list_modules() { + println!("Available Lua runtime modules:"); + for module in list_lua_modules(PathBuf::from(cli.get_sharelib()).as_path()) { + println!(" - {}", module); + } + return; + } + + // Runtime call (integrated via JSON protocol) + match get_call_args() { + Ok(rq) => match send_call_response(&call_runtime(&cli, &rq)) { + Ok(_) => {} + Err(err) => println!("Runtime error: {err}"), + }, + Err(err) => println!("Arguments error: {err}"), + } +} diff --git a/modules/runtime/lua-runtime/src/mod_doc.yaml b/modules/runtime/lua-runtime/src/mod_doc.yaml new file mode 100644 index 00000000..441b6987 --- /dev/null +++ b/modules/runtime/lua-runtime/src/mod_doc.yaml @@ -0,0 +1,71 @@ +name: "runtime.lua" +version: "0.2.0" +author: "Bo Maryniuk" +description: Lua runtime module. + +# Options, flags, switches +options: + - name: rt.list + description: "List of available Lua scripts, ready to be called as modules." + + - name: rt.logs + description: "Enable logging from Lua scripts to SysInspect logs." + + - name: rt.native + description: "Enable native Lua libraries loading. Use with caution." + +# Keyword arguments +arguments: + - name: rt.mod + type: string + required: true + description: "The name of a Lua script to be executed. This script will be looked up in the predefined configured scripts directory." + + - name: "[ANY]" + type: string + required: false + description: | + Additional arguments that will be passed to the Lua script being executed. These arguments will be available within the Lua script + as global variables. + +examples: + - description: "Call a Lua script named 'hello_world.lua' so it will greet Germany" + code: | + { + "opts": [], + "arguments": { + "rt.mod": "hello_world", + "name": "Germany" + } + } + +# Description of additional data format +returns: + # Output data structure as a sample + # Happens by default + fill: + :description: | + Returns just a regular text of the command STDOUT. + retcode: 0 + message: "Called Lua script successfully." + data: + changed: false + +manpage: | + This module allows executing Lua scripts as modules within + the SysInspect framework. Lua scripts should be placed + in the configured scripts directory and can be called by + specifying their name. + + The module supports passing additional arguments to the Lua + script, which will be available as global variables within + the script. + + Important to keep the following directory structure for your + Lua scripts and their dependencies: + + 1. The [W::b]main Lua scripts[N] (modules) for this runtime must be installed + in [y::]${SYSINSPECT_SHARELIB_ROOT}[Y::]/lib/runtime/lua54/[N] directory, + + 2. The [W::b]dependency libraries[N] for these scripts should be placed in the + [y::]${SYSINSPECT_SHARELIB_ROOT}[Y::]/lib/runtime/lua54/site-lua/[N] directory. \ No newline at end of file diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 78061f0f..e5692614 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -43,6 +43,7 @@ use libsysinspect::{ }; use once_cell::sync::Lazy; use serde_json::json; +use serde_yaml::Value as YamlValue; use std::{ fs, path::PathBuf, @@ -289,9 +290,10 @@ impl SysMinion { } RequestType::Traits => { if self.as_ptr().get_minion_id().eq(msg.target().id()) - && let Err(err) = self.as_ptr().send_traits().await { - log::error!("Unable to send traits: {err}"); - } + && let Err(err) = self.as_ptr().send_traits().await + { + log::error!("Unable to send traits: {err}"); + } } RequestType::AgentUnknown => { let pbk_pem = dataconv::as_str(Some(msg.payload()).cloned()); // Expected PEM RSA pub key @@ -801,7 +803,7 @@ pub(crate) fn launch_module(cfg: MinionConfig, args: &ArgMatches) -> Result<(), .map(|(key, value)| (key.clone(), value.clone())) .collect::>() { - modcaller.add_kwargs(k.to_string(), v.to_string()); + modcaller.add_kwargs(k.to_string(), YamlValue::String(v)); } for o in args.get_many::>("opts").unwrap_or_default().flatten().cloned().collect::>() { diff --git a/sysminion/src/ptcounter.rs b/sysminion/src/ptcounter.rs index 2f2cffc7..76861162 100644 --- a/sysminion/src/ptcounter.rs +++ b/sysminion/src/ptcounter.rs @@ -199,7 +199,7 @@ impl PTCounter { let mut top: Vec<&DiskStats> = self.disk_stats.iter().collect(); top.sort_by(|a, b| b.write_bps.partial_cmp(&a.write_bps).unwrap_or(std::cmp::Ordering::Equal)); - log::debug!( + log::trace!( "Stats: loadavg(5m)={:.2}, cpu={:.1}%, procs={}, top_writers={:#?}", self.loadaverage, self.cpu_usage,