Init commit: basic functional

This commit is contained in:
Aleksandr Berkuta 2025-06-22 19:11:12 +03:00
commit 60cd7b5c81
9 changed files with 3202 additions and 0 deletions

2511
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "orpheus"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = {version = "4", features = ["derive"]}
actix-web = "4"
env_logger = "0.9"
tera = "1"
log = "0.4"
figment = {version = "0.10", features = ["yaml"]}
serde_yaml_bw = "2"
yaml-rust2 = "0.10"
yaml-merge-keys = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
mac_address2 = "2"
[profile.release]
target = "x86_64-unknown-linux-musl"
[profile.dev]
target = "x86_64-unknown-linux-musl"

53
inventory.yaml Normal file
View File

@ -0,0 +1,53 @@
---
webserver: "http://192.168.64.134:8080"
default: &default
name: _
version: alpine-3.21
type: lts
cmdline:
- str
# - ds=nocloud;{{webserver}}/k3s-agent
# - modloop={{webserver}}/{{version}}/modloop-{{type}}
.agent: &agent
version: alpine-3.19
type: lts
cmdline: []
# - ds=nocloud;{{webserver}}/k3s-agent
# - modloop={{webserver}}/{{version}}/modloop-{{type}}
# - apkovl={{webserver}}/apkovl/some-apkovl.tar.gz
hosts:
"34:1a:4c:10:a7:08":
<<: *default
name: "hydra-0"
version: test-2.3
type: lts
cmdline:
- ds: k3s-server
- modloop
- apkovl: nginx.apkov.tar.gz
- console=tty0
- console=ASA0
"34:1a:4c:10:dc:f1":
<<: *default
<<<: *agent
name: "hydra-1"
"04:1a:4c:10:dc:f1":
<<: *agent
name: "hydra-2"
cmdline:
- init_debug
- modloop
# "34:1a:4c:10:a7:a3": # hydra-3
# "34:1a:4c:10:e2:36": # hydra-4
# "34:1a:4c:10:a6:00": # hydra-5
# "ab:cd:ef:01:23:45":
...

39
src/cli.rs Normal file
View File

@ -0,0 +1,39 @@
use clap::{Parser, Subcommand};
use log::Level;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
/// Path to the inventory file
#[arg(short, long, default_value = "inventory.yaml")]
pub inventory: String,
/// Host to bind the server
#[arg(short, long, default_value = "0.0.0.0")]
pub address: String,
/// Port to bind the server
#[arg(short, long, default_value_t = 8080)]
pub port: u16,
/// Enable debug logging
#[arg(short, long)]
pub debug: bool,
#[arg(short, long, default_value = "warn")]
pub loging: String,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub(crate) enum Commands {
Check,
Server,
}
pub fn parse_args() -> Cli {
Cli::parse()
}

207
src/config.rs Normal file
View File

@ -0,0 +1,207 @@
use crate::Inventory;
use figment::{
providers::{Format, YamlExtended},
Error as FigError, Figment,
};
use log::{error, info, warn};
use mac_address2::MacAddress;
use serde_json::json;
use std::collections::HashMap;
use std::fs::File;
use tera::Tera;
use std::io::Read;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
// #[error("Template error: {0}")]
#[error(transparent)]
Template(#[from] tera::Error),
#[error("Teplate result error: {0}")]
TemplateResult(#[from] serde_json::Error),
#[error("Inventory parsing error: {0}")]
InventorySerialization(#[from] FigError),
#[error("Inventory hosts error: {0}")]
InventoryHosts(String),
#[error("Inventory read error: {0}")]
InventoryHostsFileOpen(#[from] std::io::Error),
}
pub fn load_inventory(inventory_file: String) -> Result<Inventory, Error> {
// let file = File::open(&inventory_file).map_err(|e| Error::InventoryHostsFileOpen(e))?;
let inventory: Inventory = Figment::new()
.merge(YamlExtended::file(inventory_file))
.extract()
.map_err(|e| Error::InventorySerialization(e))?;
return Ok(inventory);
}
// pub fn parse_inventory<R: Read>(inventory_reader: R) -> Result<Inventory, Error> {
// let inventory: Inventory = YamlExtended::string(inventory_reader)
// .extract()
// .map_err(|e| Error::InventorySerialization(e))?;
//
// // Validate hosts
// if inventory.hosts.is_empty() {
// return Err(Error::InventoryHosts(
// "The 'hosts' entry is empty".to_string(),
// ));
// }
//
// // Validate each host entry
// for (mac, _) in &inventory.hosts {
// let _ = mac.parse::<MacAddress>().map_err(|e| {
// Error::InventoryHosts(format!(
// "The 'hosts' entry contains invalid MAC address: {}, error: {}",
// &mac, e
// ))
// })?;
// }
//
// Ok(inventory)
// }
pub fn validate_template(inventory: &Inventory, tera: &Tera) -> Result<(), Error> {
for (_, host) in &inventory.hosts {
let context = tera::Context::from_serialize(json!(
{ "webserver": &inventory.webserver,
"host": host
}
))
.map_err(|e| Error::Template(e))?;
// Try rendering the template for this host
let rendered_text = tera
.render("boot", &context)
.map_err(|e| Error::Template(e))?;
warn!("{}", &rendered_text);
warn!("json rendered: {:#?}", json!(&rendered_text));
let _: serde_json::Value =
serde_json::from_str(&rendered_text).map_err(|e| Error::TemplateResult(e))?;
}
Ok(())
}
// use serde::{Deserialize, Serialize};
// #[derive(Debug)]
// struct Inv<'a> {
// data: saphyr::Yaml<'a>,
// }
//
// fn parse_inventory2<'a, R: Read>(yaml_str: &'a str) -> Result<Inv<'a>, Error> {
// let yaml_str = std::fs::read_to_string("inventory.yaml")?;
// let inventory: Yaml = Yaml::load_from_str(&yaml_str).unwrap()[0].clone();
// // let mut decoder = YamlDecoder::read(yaml_str.as_bytes());
// // let out = decoder.decode().map_err(|e| Error::InventoryParse(e))?;
//
// // let inventory: Inventory = out
// println!("{:#?}", inventory);
// return unimplemented!();
// }
#[cfg(test)]
mod tests {
use super::*;
use tera::Tera;
// #[test]
// fn valid_inventory() {
// let inventory = r#"
// default:
// key: value
// hosts:
// aa:bb:cc:dd:ee:ff:
// key: value
// aa-bb-cc-dd-ee-ff:
// host_key: host2_value
// "#;
// let result = parse_inventory(inventory.as_bytes());
// result.unwrap();
// }
//
// #[test]
// fn invalid_inventory() {
// let inventory = r#"
// default:
// key: value
// hosts:
// "#;
// assert!(parse_inventory(inventory.as_bytes()).is_err());
// }
//
// #[test]
// fn invalid_host() {
// let inventory = r#"
// default:
// key: value
// hosts:
// my_test_host:
// key: value
// "#;
// assert!(parse_inventory(inventory.as_bytes()).is_err());
// }
//
// #[test]
// fn valid_template() {
// let inventory_str = r#"
// default:
// key1: value1
// hosts:
// aa:bb:cc:dd:ee:ff:
// host_key: host_value
// "#;
// let inventory: Inventory = serde_yaml_bw::from_str(inventory_str).unwrap();
// let template = r#"
// {
// "host": "{{ host.host_key }}",
// "default": "{{ default.key1 }}"
// }
// "#;
// let mut tera = Tera::default();
// tera.add_raw_templates(vec![("boot", template)]).unwrap();
// validate_template(&inventory, &tera).unwrap();
// }
//
// #[test]
// fn template_unexisted_variable_render() {
// let inventory_str = r#"
// default:
// key1: value1
// hosts:
// aa:bb:cc:dd:ee:ff:
// host_key: host_value
// "#;
// let inventory: Inventory = serde_yaml_bw::from_str(inventory_str).unwrap();
// let template = r#"
// "{{ unexisted.variable }}",
// "#;
// let mut tera = Tera::default();
// tera.add_raw_templates(vec![("boot", template)]).unwrap();
// assert!(validate_template(&inventory, &tera).is_err());
// }
//
// #[test]
// fn template_result_invalid_json() {
// let inventory_str = r#"
// default:
// key1: value1
// hosts:
// aa:bb:cc:dd:ee:ff:
// host_key: host1_value
// "#;
// let inventory: Inventory = serde_yaml_bw::from_str(inventory_str).unwrap();
// let template = r#"
// Not a valid JSON string containing variable: {{ host.host_key }}
// "#;
// let mut tera = Tera::default();
// tera.add_raw_templates(vec![("boot", template)]).unwrap();
// assert!(validate_template(&inventory, &tera).is_err());
// }
}

24
src/handlers/boot.rs Normal file
View File

@ -0,0 +1,24 @@
use crate::inventory::Inventory;
use actix_web::{web, HttpResponse, Responder};
use log::{error, info, warn};
use serde_json::json;
use std::sync::Arc;
use tera::Tera;
pub async fn boot_handler(
mac: web::Path<String>,
inventory: web::Data<Arc<Inventory>>,
) -> impl Responder {
let mac_address = mac.into_inner();
if let Ok(boot_paramter) = inventory.get(&mac_address) {
return HttpResponse::Ok()
.content_type("application/json")
.body(format!("{}", json!(boot_paramter)));
} else {
HttpResponse::NotFound()
.content_type("application/json")
.body(json!({"error": "Unknown host ", "host": mac_address }).to_string())
}
}

1
src/handlers/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod boot;

269
src/inventory.rs Normal file
View File

@ -0,0 +1,269 @@
use serde::de::{self, Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
use serde_yaml_bw::Value as YamlValue;
use std::cmp;
use std::collections::HashMap;
use std::fmt;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct Inventory {
pub webserver: String,
pub hosts: HashMap<String, Host>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub(crate) struct Host {
pub(crate) name: String,
pub(crate) version: String,
pub(crate) r#type: String,
pub(crate) cmdline: Vec<CmdParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub(crate) struct BootParam {
kernel: String,
initrd: Vec<String>,
cmdline: String,
message: String,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub(crate) enum CmdParams {
DataSource(String),
Apkovl(String),
Modloop,
String(String),
}
impl Inventory {
fn build_cmdline(&self, host: &str) -> String {
let host = self.hosts.get(host).unwrap();
let mut result_string = String::new();
for param in &host.cmdline {
match param {
CmdParams::DataSource(path) => result_string.push_str(&format!(
"ds=nocloud;s={}/tiny-cloud/{}",
self.webserver, path
)),
CmdParams::Apkovl(path) => {
result_string.push_str(&format!("apkovl={}/apkovl/{}", self.webserver, path))
}
CmdParams::Modloop => result_string.push_str(&format!(
"modloop={}/{}/modloop-{}",
self.webserver, host.version, host.r#type
)),
CmdParams::String(cmdstring) => result_string.push_str(&cmdstring),
};
result_string.push_str(" ");
}
// remove last space
result_string = result_string.trim().into();
return result_string;
}
pub fn get(&self, mac_addr: &str) -> Result<BootParam, ()> {
let host = self.hosts.get(mac_addr).unwrap();
let boot_param = BootParam {
kernel: format!(
"{}/{}/vmlinuz-{}",
self.webserver, host.version, host.r#type
)
.into(),
initrd: vec![format!(
"{}/{}/initramfs-{}",
self.webserver, host.version, host.r#type
)
.into()],
cmdline: self.build_cmdline(&mac_addr),
message: format!("Booting host: {}", &host.name).into(),
};
return Ok(boot_param);
}
}
impl<'de> Deserialize<'de> for CmdParams {
fn deserialize<D>(deserializer: D) -> Result<CmdParams, D::Error>
where
D: Deserializer<'de>,
{
struct CmdParamsVisitor;
impl<'de> Visitor<'de> for CmdParamsVisitor {
type Value = CmdParams;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a cmdline parameter in the form of a string or map")
}
fn visit_str<E>(self, value: &str) -> Result<CmdParams, E>
where
E: de::Error,
{
if value == "modloop" {
return Ok(CmdParams::Modloop);
} else {
return Ok(CmdParams::String(value.to_string()));
}
}
fn visit_map<M>(self, mut access: M) -> Result<CmdParams, M::Error>
where
M: MapAccess<'de>,
{
if let Some((key, value)) = access.next_entry::<String, Option<String>>()? {
match key.as_str() {
"ds" => {
let value =
value.ok_or_else(|| de::Error::missing_field("ds value"))?;
Ok(CmdParams::DataSource(value))
}
"apkovl" => {
let value =
value.ok_or_else(|| de::Error::missing_field("apkovl value"))?;
Ok(CmdParams::Apkovl(value))
}
"modloop" => Ok(CmdParams::Modloop),
_ => Err(de::Error::unknown_field(&key, &["ds", "apkovl", "modloop"])),
}
} else {
Err(de::Error::custom("Expected a key-value pair"))
}
}
}
deserializer.deserialize_any(CmdParamsVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml_bw as serde_yaml;
#[test]
fn cmdparams_deserialization_string() {
let yaml_data = "---\nconsole=tty0";
let cmd_param: CmdParams = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(cmd_param, CmdParams::String("console=tty0".to_string()));
}
#[test]
fn cmdparams_deserialization_modloop() {
let yaml_data = "---\nmodloop";
let cmd_param: CmdParams = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(cmd_param, CmdParams::Modloop);
}
#[test]
fn cmdparams_deserialization_ds() {
let yaml_data = "---\nds: k3s-server";
let cmd_param: CmdParams = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(cmd_param, CmdParams::DataSource("k3s-server".to_string()));
}
#[test]
fn cmdparams_deserialization_apkovl() {
let yaml_data = "---\napkovl: nginx.apkov.tar.gz";
let cmd_param: CmdParams = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(
cmd_param,
CmdParams::Apkovl("nginx.apkov.tar.gz".to_string())
);
}
#[test]
fn cmdparams_deserialization_invalid() {
let yaml_data = "---\nunknown: value";
let result: Result<CmdParams, _> = serde_yaml::from_str(yaml_data);
assert!(result.is_err());
}
// test Host deserialization
#[test]
fn host_deserialization_valid() {
let yaml_data = r#"---
name: hostname
version: alpine-v3.21
type: test
cmdline:
- console=tty0
"#;
let host: Host = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(
host,
Host {
name: "hostname".to_string(),
version: "alpine-v3.21".to_string(),
r#type: "test".to_string(),
cmdline: vec!(CmdParams::String("console=tty0".to_string()))
}
);
let yaml_data = r#"---
name: hostname
version: alpine-v3.21
type: test
cmdline: []
"#;
let host: Host = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(
host,
Host {
name: "hostname".to_string(),
version: "alpine-v3.21".to_string(),
r#type: "test".to_string(),
cmdline: vec!()
}
);
}
#[test]
fn host_deserialization_invalid() {
let yaml_data = r#"---
version: alpine-v3.21
type: test
cmdline: []
"#;
let host: Result<Host, _> = serde_yaml::from_str(yaml_data);
assert!(host.is_err());
}
#[test]
fn build_cmdline() {
use serde_json::json;
let host = Host {
name: "test".into(),
version: "test".into(),
r#type: "test".into(),
cmdline: vec![
CmdParams::String("test".into()),
CmdParams::Apkovl("apkovl.tar.gz".into()),
CmdParams::Modloop,
CmdParams::DataSource("test-ds-config".into()),
],
};
let mut hosts: HashMap<String, Host> = HashMap::new();
hosts.insert("a:b:c:d:e:f".into(), host.clone());
let inventory = Inventory {
webserver: "http://example.net".into(),
hosts: hosts,
};
let cmd_string = inventory.build_cmdline("a:b:c:d:e:f");
assert_eq!(
cmd_string,
"test apkovl=http://example.net/apkovl/apkovl.tar.gz modloop=http://example.net/test/modloop-test ds=nocloud;s=http://example.net/tiny-cloud/test-ds-config"
);
assert_eq!(
json!(inventory.get("a:b:c:d:e:f").unwrap()),
json!({
"kernel": "http://example.net/test/vmlinuz-test",
"initrd": ["http://example.net/test/initramfs-test"],
"cmdline": "test apkovl=http://example.net/apkovl/apkovl.tar.gz modloop=http://example.net/test/modloop-test ds=nocloud;s=http://example.net/tiny-cloud/test-ds-config",
"message": "Booting host: test"})
);
}
}

73
src/main.rs Normal file
View File

@ -0,0 +1,73 @@
mod cli;
mod config;
mod handlers;
mod inventory;
use crate::cli::*;
use crate::config::{load_inventory, validate_template};
use crate::handlers::boot::boot_handler;
use crate::inventory::Inventory;
use actix_web::{web, App, HttpServer};
use env_logger;
use log::{error, info, warn};
use std::error::Error;
use std::sync::Arc;
use tera::Tera;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let cli = parse_args();
std::env::set_var("RUST_LOG", cli.loging);
env_logger::init(); // Initialize the logger
let inventory: Inventory = match load_inventory(cli.inventory) {
Ok(inv) => inv,
Err(e) => {
error!("Error loading inventory: {:#?}", e);
std::process::exit(1);
}
};
// create template engine instance
let mut tera = Tera::default();
match tera.add_template_file("templates/boot.json.tmpl", Some("boot")) {
Ok(_) => {}
Err(e) => {
error!(
"Error loading template: {}, reason:\n{}",
e,
e.source().unwrap()
);
std::process::exit(1);
}
};
if let Err(e) = validate_template(&inventory, &tera) {
error!("Template validation error: {}, {:#?}", e, e.source());
std::process::exit(1);
}
// wrap objects into Atomic Reference Counter to use in multithead
// Actix environment
let inventory = Arc::new(inventory);
let tera = Arc::new(tera);
match cli.command {
Commands::Check => {
print!("inventory is valid: {:#?}", inventory);
return Ok(());
}
Commands::Server => {
return HttpServer::new(move || {
App::new()
.app_data(web::Data::new(inventory.clone()))
.app_data(web::Data::new(tera.clone()))
.route("/v1/boot/{mac}", web::get().to(boot_handler))
})
.bind((cli.address, cli.port))?
.run()
.await
}
};
}