refactor: modularize codebase and improve type safety

This commit is contained in:
2026-02-18 03:39:29 -07:00
parent 57a3287f38
commit 8877c61aed
10 changed files with 358 additions and 327 deletions

2
Cargo.lock generated
View File

@@ -717,7 +717,7 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "skyforge"
version = "0.1.0"
version = "0.1.112"
dependencies = [
"anyhow",
"clap",

View File

@@ -1,6 +1,6 @@
[package]
name = "skyforge"
version = "0.1.0"
version = "0.1.112"
edition = "2024"
[dependencies]

View File

@@ -47,10 +47,10 @@ Options:
-V, --version Print version
Environment:
SF_SPEC_PATH Path to the directory containing templates. Defaults to "./spec".
SF_TMPL_PATH Path to the directory containing specifications. Defaults to "./tmpl".
SF_OUT_PATH Path to the directory for command output. Defaults to "./out".
SF_LOG_PATH Path to the directory for log output. Defaults to "./log".
SF_SPEC_PATH Directory containing templates
SF_TMPL_PATH Directory containing specifications
SF_OUT_PATH Directory for command output
SF_LOG_PATH Directory for log output
```
### Standard

View File

@@ -1,2 +1,2 @@
device:
hostname: xyz2-ex-core-r202
hostname: xyz2-ex-core-r102

View File

@@ -1,81 +1,7 @@
use crate::log::LogLevel;
use clap::{Arg, ArgAction, ArgGroup, Command};
use regex::Regex;
use std::fmt;
#[derive(Debug)]
pub struct Args {
pub devices: Regex,
pub loglevel: LogLevel,
pub env: EnvVars,
}
impl fmt::Display for Args {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"devices: {}, loglevel: {}, env: {}",
self.devices, self.loglevel, self.env
)
}
}
#[derive(Debug)]
pub struct EnvVars {
pub spec_path: String,
pub tmpl_path: String,
pub out_path: String,
pub log_path: String,
}
impl fmt::Display for EnvVars {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"spec_path: {}, tmpl_path: {}, out_path: {}, log_path: {}",
self.spec_path, self.tmpl_path, self.out_path, self.log_path
)
}
}
/// loads `command line arguments` and `environment variables` using custom `clap`
pub fn parse_args() -> Args {
let matches: clap::ArgMatches = build().get_matches();
let raw_devices = matches.get_one::<String>("devices").unwrap();
Args {
devices: Regex::new(&raw_devices).expect("Invalid regex pattern provided for `devices`"),
loglevel: if matches.get_flag("debug") {
LogLevel::Debug
} else if matches.get_flag("verbose") {
LogLevel::Verbose
} else {
LogLevel::Info
},
env: parse_env(),
}
}
/// loads environment variables
pub fn parse_env() -> EnvVars {
EnvVars {
spec_path: match std::env::var("SF_SPEC_PATH") {
Ok(path) => path.trim_end_matches('/').to_string(),
Err(_) => String::from("./spec"),
},
tmpl_path: match std::env::var("SF_TMPL_PATH") {
Ok(path) => path.trim_end_matches('/').to_string(),
Err(_) => String::from("./tmpl"),
},
out_path: match std::env::var("SF_OUT_PATH") {
Ok(path) => path.trim_end_matches('/').to_string(),
Err(_) => String::from("./out"),
},
log_path: match std::env::var("SF_LOG_PATH") {
Ok(path) => path.trim_end_matches('/').to_string(),
Err(_) => String::from("./log"),
},
}
}
use std::path::PathBuf;
const ABOUT_MSG: &str = r#"Skyforge Config Generation Engine"#;
const ENV_MSG: &str = r#"Environment:
@@ -85,38 +11,90 @@ const ENV_MSG: &str = r#"Environment:
SF_LOG_PATH Directory for log output
"#;
/// builds a custom command line argument parser
fn build() -> Command {
Command::new("app")
.about(ABOUT_MSG)
.version(env!("CARGO_PKG_VERSION"))
.author("rskntroot")
.arg(
Arg::new("devices")
.long("devices")
.short('d')
.help("A regular expression pattern")
.required(true),
)
.arg(
Arg::new("debug")
.long("debug")
.help("Print debug information")
.action(ArgAction::SetTrue)
.required(false),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Print verbose information")
.action(ArgAction::SetTrue)
.required(false),
)
.group(
ArgGroup::new("loglevel")
.args(&["debug", "verbose"])
.required(false),
)
.after_help(ENV_MSG)
#[derive(Debug)]
pub struct Args {
pub devices: Regex,
pub loglevel: LogLevel,
pub env: EnvVars,
}
impl Args {
/// loads command line arguments and environment variables
pub fn parse() -> Args {
let matches: clap::ArgMatches = Self::parser().get_matches();
let raw_devices = matches.get_one::<String>("devices").unwrap();
Args {
devices: Regex::new(&raw_devices)
.expect("Invalid regex pattern provided for `devices`"),
loglevel: if matches.get_flag("debug") {
LogLevel::Debug
} else if matches.get_flag("verbose") {
LogLevel::Verbose
} else {
LogLevel::Info
},
env: EnvVars::parse(),
}
}
fn parser() -> Command {
Command::new("skyforge")
.about(ABOUT_MSG)
.version(env!("CARGO_PKG_VERSION"))
.author("rskntroot")
.arg(
Arg::new("devices")
.long("devices")
.short('d')
.help("A regular expression pattern")
.required(true),
)
.arg(
Arg::new("debug")
.long("debug")
.help("Print debug information")
.action(ArgAction::SetTrue)
.required(false),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Print verbose information")
.action(ArgAction::SetTrue)
.required(false),
)
.group(
ArgGroup::new("loglevel")
.args(&["debug", "verbose"])
.required(false),
)
.after_help(ENV_MSG)
}
}
#[derive(Debug)]
pub struct EnvVars {
pub spec_path: PathBuf,
pub tmpl_path: PathBuf,
pub out_path: PathBuf,
pub log_path: PathBuf,
}
impl EnvVars {
pub fn parse() -> Self {
Self {
spec_path: std::env::var("SF_SPEC_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./spec")),
tmpl_path: std::env::var("SF_TMPL_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./tmpl")),
out_path: std::env::var("SF_OUT_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./out")),
log_path: std::env::var("SF_LOG_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./log")),
}
}
}

10
src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod cli;
pub mod log;
pub mod render;
pub mod spec;
pub mod tmpl;
pub use cli::Args;
pub use log::LogLevel;
pub use render::DeviceConfigBundle;
pub use spec::Specification;

View File

@@ -1,27 +1,11 @@
mod cli;
mod log;
mod render;
mod spec;
mod tmpl;
use skyforge::{Args, DeviceConfigBundle, Specification};
use log::LogLevel;
use render::RenderedConfig;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
fn main() {
let args: cli::Args = cli::parse_args();
let dbg: LogLevel = args.loglevel;
dbug!(dbg, "{:#?}", &args);
let specifications: Vec<spec::Specification> =
spec::compile(&args.devices, &args.env.spec_path, dbg);
for spec in specifications {
RenderedConfig::from_spec(&spec, dbg)
.unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
})
.output(&args.env.out_path, dbg);
for spec in Specification::compile(&args) {
DeviceConfigBundle::from_spec(spec)?.output_artifacts();
}
Ok(())
}

View File

@@ -1,13 +1,19 @@
use crate::log::LogLevel;
use crate::spec::Specification;
use crate::tmpl;
use crate::{LogLevel, info, verb};
use serde_json::Value;
use crate::{dbug, info, verb};
use std::io::Write;
pub struct RenderedConfig {
pub struct DeviceConfigBundle {
pub hostname: String,
pub configs: Vec<ConfigVariant>,
pub spec: Specification,
pub platform: String,
}
pub struct ConfigVariant {
pub name: String,
pub configs: Vec<Configuration>,
pub spec: Value,
}
pub struct Configuration {
@@ -15,15 +21,12 @@ pub struct Configuration {
pub data: String,
}
impl RenderedConfig {
pub fn from_spec(
spec: &Specification,
dbg: LogLevel,
) -> Result<Self, Box<dyn std::error::Error>> {
let context = Self::get_context(&spec);
impl DeviceConfigBundle {
pub fn from_spec(spec: Specification) -> Result<Self, Box<dyn std::error::Error>> {
let mut context = Self::get_context(&spec);
let hostname = Self::get_hostname(&context, &spec);
info!(dbg, "Rendering {}", hostname);
info!(&spec.loglevel, "Rendering {}", hostname);
let base_dir = std::path::Path::new("./tmpl").join(spec.get_layer());
let structure_path = base_dir.join("structure.yaml");
@@ -31,28 +34,38 @@ impl RenderedConfig {
.map_err(|e| format!("Failed to parse {}: {}", structure_path.display(), e))?;
let mut renderer = tera::Tera::default();
renderer.add_raw_templates(structure.load_template_data(&base_dir))?;
renderer.add_raw_templates(structure.load_template_data(&base_dir, spec.loglevel))?;
dbug!(&spec.loglevel, "Processing templates for {}", hostname);
let mut configs = Vec::new();
for template_name in &structure.files {
verb!(dbg, " | {}", &template_name);
if !renderer.get_template_names().any(|n| n == template_name) {
continue;
}
match renderer.render(template_name, &context) {
Ok(rendered) => configs.push(Configuration {
name: template_name.clone(),
data: rendered,
}),
Err(e) => Self::log_tera_error(template_name, &e),
let mut config_variants = Vec::new();
for variant in &structure.variations {
context.insert(variant, &true);
let mut configs = Vec::new();
for template_name in &structure.files {
dbug!(&spec.loglevel, " | {}.{}", &template_name, variant);
if !renderer.get_template_names().any(|n| n == template_name) {
continue;
}
match renderer.render(template_name, &context) {
Ok(data) => configs.push(Configuration {
name: template_name.clone(),
data,
}),
Err(e) => Self::log_tera_error(template_name, &e),
}
}
config_variants.push(ConfigVariant {
name: String::from(variant),
configs,
});
context.remove(variant);
}
Ok(Self {
hostname,
configs,
spec: spec.compiled.clone(),
configs: config_variants,
spec,
platform: structure.platform,
})
}
@@ -61,13 +74,7 @@ impl RenderedConfig {
.get("hostname")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
spec.device
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
})
.unwrap_or_else(|| spec.components.get_hostname())
}
fn get_context(spec: &Specification) -> tera::Context {
@@ -90,33 +97,43 @@ impl RenderedConfig {
eprintln!("[tera] {}: {}", name, chain);
}
pub fn output(self: Self, outdir: &str, dbg: LogLevel) {
info!(dbg, "Writing Output:");
pub fn output_artifacts(self: Self) {
info!(&self.spec.loglevel, "Writing Output:");
let out_path = std::path::Path::new(outdir).join(self.hostname);
if out_path.exists() {
std::fs::remove_dir_all(&out_path).ok();
let device_outpath = std::path::Path::new(&self.spec.outpath).join(self.hostname);
if device_outpath.exists() {
std::fs::remove_dir_all(&device_outpath).ok();
}
std::fs::create_dir_all(&out_path).ok();
std::fs::create_dir_all(&device_outpath).ok();
let merged_config_path = out_path.join("all.conf");
let mut all_file: std::fs::File = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&merged_config_path)
.expect("unable to open");
for variant_configs in &self.configs {
let out_path = device_outpath.join(&variant_configs.name);
std::fs::create_dir_all(&out_path).ok();
for config in &self.configs {
let template_path = out_path.join(format!("{}.tera", &config.name));
verb!(dbg, " | {}", &template_path.display());
std::fs::write(&template_path, &config.data).ok();
all_file.write_all(&config.data.as_bytes()).ok();
let merged_config_path =
device_outpath.join(format!("all.{}.{}", variant_configs.name, &self.platform));
let mut all_file: std::fs::File = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&merged_config_path)
.expect("unable to open");
for config in &variant_configs.configs {
let config_outpath = out_path.join(format!("{}.{}", &config.name, &self.platform));
std::fs::create_dir_all(&config_outpath).ok();
verb!(&self.spec.loglevel, " | {}", &config_outpath.display());
std::fs::write(&config_outpath, &config.data).ok();
all_file.write_all(&config.data.as_bytes()).ok();
}
info!(&self.spec.loglevel, " | {} ", &merged_config_path.display());
}
let spec: String = yaml_serde::to_string(&self.spec).unwrap();
let compiled_spec_path = out_path.join("compiled-spec.yaml");
verb!(dbg, " | {}", &compiled_spec_path.display());
std::fs::write(&compiled_spec_path, spec).ok();
info!(dbg, " | {} ", &merged_config_path.display());
let compiled_spec_path = device_outpath.join("context.yaml");
verb!(&self.spec.loglevel, " | {}", &compiled_spec_path.display());
std::fs::write(
&compiled_spec_path,
yaml_serde::to_string(&self.spec.compiled).unwrap(),
)
.ok();
}
}

View File

@@ -1,19 +1,17 @@
use crate::cli::Args;
use crate::{LogLevel, info, verb};
use regex::Regex;
use serde_json::{Map, Value, json};
use std::path::{Path, PathBuf};
use std::{env, fmt, fs};
use std::path::PathBuf;
use walkdir::WalkDir;
use yaml_serde;
#[derive(Debug)]
pub struct Specification {
pub partitional: PathBuf,
pub regional: PathBuf,
pub common: PathBuf,
pub zonal: PathBuf,
pub device: PathBuf,
pub compiled: Value,
pub components: Components,
pub loglevel: LogLevel,
pub outpath: PathBuf,
}
impl Specification {
@@ -23,10 +21,12 @@ impl Specification {
common: PathBuf,
zonal: PathBuf,
device: PathBuf,
loglevel: LogLevel,
outpath: PathBuf,
) -> Result<Self, Box<dyn std::error::Error>> {
let json_values: Vec<Value> = [&partitional, &regional, &common, &zonal, &device]
.iter()
.filter_map(|path| fs::read_to_string(path).ok())
.filter_map(|path| std::fs::read_to_string(path).ok())
.map(|content| yaml_serde::from_str::<Value>(&content))
.collect::<Result<Vec<Value>, _>>()?;
@@ -40,17 +40,22 @@ impl Specification {
let compiled = json!({"data": merged_map});
Ok(Self {
partitional,
regional,
common,
zonal,
device,
compiled,
components: Components {
partitional,
regional,
common,
zonal,
device,
},
loglevel,
outpath,
})
}
pub fn get_layer(&self) -> String {
self.device
self.components
.device
.parent()
.unwrap()
.file_name()
@@ -60,126 +65,157 @@ impl Specification {
.filter(|c| !c.is_numeric())
.collect::<String>()
}
/// compiles specifications from regex and paths provided by Args
pub fn compile(args: &Args) -> Vec<Specification> {
let spec_list: Vec<PathBuf> = Self::filter_specs(
&args.devices,
Self::get_specs(&args.env.spec_path, args.loglevel),
);
info!(
&args.loglevel,
"Matched {} devices against '{}'",
&spec_list.len(),
&args.devices
);
spec_list
.into_iter()
.map(|spec| {
verb!(&args.loglevel, " | {}", spec.display());
Self::build(
Self::get_partitional(&Self::get_common(&spec), &Self::get_regional(&spec)),
Self::get_regional(&spec),
Self::get_common(&spec),
Self::get_zonal(&spec),
spec,
args.loglevel,
std::path::PathBuf::from(&args.env.out_path),
)
.unwrap_or_else(|e| panic!("failed to build Specification: {}", e))
})
.collect()
}
fn get_partitional(common: &PathBuf, regional: &PathBuf) -> PathBuf {
let re = Regex::new(r"partition: ([^\n\r$]+)").expect("failed");
[common, regional]
.iter()
.find_map(|path| {
std::fs::read_to_string(path).ok().and_then(|contents| {
re.captures(&contents).and_then(|captures| {
captures.get(1).map(|matched| {
PathBuf::from(format!("./spec/common/{}.yaml", matched.as_str().trim()))
})
})
})
})
.unwrap_or_else(|| PathBuf::from("./spec/common/default.yaml"))
}
fn get_regional(path: &PathBuf) -> PathBuf {
let dir_name = path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
path.parent()
.unwrap()
.parent()
.unwrap()
.join("common")
.join(format!("{}.yaml", &dir_name[0..2]))
}
fn get_common(spec: &PathBuf) -> PathBuf {
spec.with_file_name("common.yaml")
}
fn get_zonal(spec: &PathBuf) -> PathBuf {
let zone = spec
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.split_once('-'))
.map(|(zone, _)| spec.with_file_name(zone))
.unwrap_or_else(|| spec.with_file_name("common.yaml"));
PathBuf::from(format!("{}.common.yaml", zone.to_str().unwrap()))
}
fn filter_specs(pattern: &Regex, spec_list: Vec<PathBuf>) -> Vec<PathBuf> {
spec_list
.into_iter()
.filter(|spec| {
spec.file_name()
.and_then(|name| name.to_str())
.map_or(false, |name| pattern.is_match(name))
})
.collect()
}
fn get_specs(spec_path: &PathBuf, dbg: LogLevel) -> Vec<PathBuf> {
let mut result = Vec::new();
let root = spec_path.as_path();
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
if let Ok(path) = entry.path().strip_prefix(root) {
let path_str = path.to_string_lossy().to_string();
if !path_str.contains("common") && path_str.ends_with("yaml") {
result.push(root.join(path));
}
}
}
info!(
dbg,
"Skyforge found {} renderable devices in {}",
result.len(),
std::env::current_dir().unwrap().display()
);
result
}
}
impl fmt::Display for Specification {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
impl std::fmt::Display for Specification {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"Specification:\nPartitional: {}\nRegional: {}\nCommon: {}\nZonal: {}\nDevice: {}\nCompiled:\n{}",
"Compiled Specification: {}\nLogLevel: {}\nComponents: {}",
self.compiled, self.loglevel, self.components,
)
}
}
#[derive(Debug)]
pub struct Components {
partitional: PathBuf,
regional: PathBuf,
common: PathBuf,
zonal: PathBuf,
device: PathBuf,
}
impl Components {
pub fn get_hostname(self: &Self) -> String {
self.device
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
}
impl std::fmt::Display for Components {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
" | Partitional: {}\n | Regional: {}\n | Common: {}\n | Zonal: {}\n | Device: {}\n",
self.partitional.display(),
self.regional.display(),
self.common.display(),
self.zonal.display(),
self.device.display(),
self.compiled
)
}
}
pub fn compile(pattern: &Regex, spec_path: &String, dbg: LogLevel) -> Vec<Specification> {
let spec_list: Vec<PathBuf> = filter_specs(pattern, get_specs(spec_path, dbg));
info!(
dbg,
"Matched {} devices against '{}'",
&spec_list.len(),
&pattern
);
for spec in &spec_list {
verb!(dbg, " | {}", spec.display());
}
let mut specifications: Vec<Specification> = Vec::new();
for spec in spec_list {
let zonal = get_zonal(&spec);
let common = get_common(&spec);
let regional = get_regional(&spec);
let partitional = get_partitional(&common, &regional);
specifications.push(
match Specification::build(partitional, regional, common, zonal, spec) {
Ok(compiled_spec) => compiled_spec,
Err(e) => panic!("failed to build Specification: {}", e),
},
)
}
specifications
}
fn get_partitional(common: &PathBuf, regional: &PathBuf) -> PathBuf {
let re = Regex::new(r"partition: ([^\n\r$]+)").expect("failed");
[common, regional]
.iter()
.find_map(|path| {
fs::read_to_string(path).ok().and_then(|contents| {
re.captures(&contents).and_then(|captures| {
captures.get(1).map(|matched| {
PathBuf::from(format!("./spec/common/{}.yaml", matched.as_str().trim()))
})
})
})
})
.unwrap_or_else(|| PathBuf::from("./spec/common/default.yaml"))
}
fn get_regional(path: &PathBuf) -> PathBuf {
let dir_name = path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
path.parent()
.unwrap()
.parent()
.unwrap()
.join("common")
.join(format!("{}.yaml", &dir_name[0..2]))
}
fn get_common(spec: &PathBuf) -> PathBuf {
spec.with_file_name("common.yaml")
}
fn get_zonal(spec: &PathBuf) -> PathBuf {
let zone = spec
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.split_once('-'))
.map(|(zone, _)| spec.with_file_name(zone))
.unwrap_or_else(|| spec.with_file_name("common.yaml"));
PathBuf::from(format!("{}.common.yaml", zone.to_str().unwrap()))
}
fn filter_specs(pattern: &Regex, spec_list: Vec<PathBuf>) -> Vec<PathBuf> {
spec_list
.into_iter()
.filter(|spec| {
spec.file_name()
.and_then(|name| name.to_str())
.map_or(false, |name| pattern.is_match(name))
})
.collect()
}
fn get_specs(spec_path: &String, dbg: LogLevel) -> Vec<PathBuf> {
let mut result = Vec::new();
let root = Path::new(&spec_path);
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
if let Ok(path) = entry.path().strip_prefix(root) {
let path_str = path.to_string_lossy().to_string();
if !path_str.contains("common") && path_str.ends_with("yaml") {
result.push(root.join(path));
}
}
}
info!(
dbg,
"Skyforge found {} renderable devices in {}",
result.len(),
env::current_dir().unwrap().display()
);
result
}

View File

@@ -1,12 +1,11 @@
use crate::{LogLevel, crit, info};
use serde::Deserialize;
use yaml_serde;
#[derive(Deserialize)]
pub struct Structure {
pub files: Vec<String>,
#[allow(dead_code)] //todo
pub platform: String,
#[allow(dead_code)] //todo
pub variations: Vec<String>,
}
@@ -17,15 +16,22 @@ impl Structure {
Ok(yaml_serde::from_str(&data)?)
}
pub fn load_template_data(&self, base_dir: &std::path::Path) -> Vec<(String, String)> {
pub fn load_template_data(
&self,
tmpl_dir: &std::path::Path,
dbg: LogLevel,
) -> Vec<(String, String)> {
self.files
.iter()
.filter_map(|name| {
let path = base_dir.join(format!("{}.tera", name));
match std::fs::read_to_string(&path) {
Ok(content) => Some((name.clone(), content)),
let template = tmpl_dir.join(format!("{}.tera", name));
match std::fs::read_to_string(&template) {
Ok(content) => {
info!(dbg, " | {}", template.display());
Some((name.clone(), content))
}
Err(e) => {
eprintln!("Skipping {}: {}", path.display(), e);
crit!(dbg, "Skipping {}: {}", template.display(), e);
None
}
}