feat: Initial rewrite of python render tool

This commit is contained in:
2026-03-16 03:14:33 +01:00
commit e4d39de2f0
24 changed files with 4453 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use gix_discover::repository::Path;
#[derive(Debug, Parser)]
#[command(version, about)]
#[command(propagate_version = true)]
pub struct Cli {
#[command(flatten)]
pub global_opts: GlobalOpts,
#[command(subcommand)]
pub command: Commands,
}
fn path_is_repo(path: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(path);
let (repo, _trust) = gix_discover::upwards(&path).map_err(|err| err.to_string())?;
let work_dir = match repo {
Path::LinkedWorkTree { work_dir, .. } => work_dir,
Path::WorkTree(work_dir) => work_dir,
Path::Repository(git_dir) => {
return Err(format!(
"Repo '{}' has no working directory",
git_dir.to_string_lossy()
));
}
};
Ok(work_dir)
}
#[derive(Debug, Args)]
pub struct GlobalOpts {
#[arg(global = true, short, long, action = clap::ArgAction::Count)]
/// Use verbose output
pub verbose: u8,
#[arg(global = true, short, long, value_parser = path_is_repo, default_value_os_t = PathBuf::from("."))]
/// Path to the repo containing the config files
pub repo: PathBuf,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
/// Generate talos config file
Generate,
/// Generate completions for your current shell
ShellCompletions,
}
+138
View File
@@ -0,0 +1,138 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::str::FromStr;
use minijinja::Environment;
use optional_struct::{Applicable, optional_struct};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::get_talos_config_path;
use crate::node::{Node, OptionalNode};
use crate::patch::Patches;
#[optional_struct]
#[derive(Debug, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Base {
#[serde(default)]
pub(crate) kernel_args: Vec<String>,
#[serde(default)]
pub(crate) patches: Patches,
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Version {
kubernetes: semver::Version,
talos: semver::Version,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub enum ClusterEnv {
Production,
Staging,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum NodeEntry {
Name(String),
#[serde(skip_deserializing)]
Node(Box<Node>),
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Cluster {
#[serde(skip_deserializing)]
name: String,
#[optional_rename(OptionalVersion)]
#[optional_wrap]
version: Version,
nodes: Vec<NodeEntry>,
cluster_env: ClusterEnv,
control_plane_ip: Ipv4Addr,
secrets_file: PathBuf,
#[serde(default, skip_serializing)]
#[optional_skip_wrap]
pub(crate) default: OptionalNode,
#[optional_rename(OptionalBase)]
#[optional_wrap]
#[serde(skip_serializing)]
pub(crate) base: Base,
// pub secrets_file: PathBuf,
}
impl Cluster {
pub fn get(cluster_name: &str, env: &Environment) -> Self {
let path = get_talos_config_path()
.join("clusters")
.join("default.yaml");
let default: OptionalCluster = if path.exists() {
let content = std::fs::read_to_string(path).unwrap();
serde_yaml::from_str(&content).unwrap()
} else {
Default::default()
};
let mut path = get_talos_config_path().join("clusters").join(cluster_name);
path.add_extension("yaml");
let content = std::fs::read_to_string(path).unwrap();
let mut cluster: OptionalCluster = serde_yaml::from_str(&content).unwrap();
cluster.name = Some(cluster_name.to_string());
// For some reason apply on the cluster does not properly apply to the default settings...
// So we manually apply it here first
cluster.default = default.default.clone().apply(cluster.default);
let mut cluster: Self = default.apply(cluster).try_into().unwrap();
cluster.nodes = cluster
.nodes
.clone()
.into_iter()
.map(|node_entry| {
if let NodeEntry::Name(name) = node_entry {
NodeEntry::Node(Box::new(Node::get(&name, env, &cluster)))
} else {
node_entry
}
})
.collect();
cluster.secrets_file = get_talos_config_path()
.join("secrets")
.join(cluster.secrets_file)
.absolute()
.unwrap();
cluster
}
}
pub fn get_clusters(env: &Environment) -> Vec<Cluster> {
WalkDir::new(get_talos_config_path().join("clusters"))
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.metadata().unwrap().is_file())
.filter_map(|e| {
let mut path = PathBuf::from_str(&e.file_name().to_string_lossy()).unwrap();
path.set_extension("");
let name = path.to_string_lossy().to_string();
if name == "default" {
return None;
}
Some(Cluster::get(&name, env))
})
.collect()
}
+110
View File
@@ -0,0 +1,110 @@
use std::net::Ipv4Addr;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use minijinja::{AutoEscape, Environment, path_loader};
use walkdir::WalkDir;
use crate::{get_repo_path, get_talos_config_path};
/// Transparent wrapper around minijinja::Environment that loads templates from a path and
/// configures better defaults. It also implements IntoIter, making it possible to iterate over all
/// the templates.
pub struct PathEnvironment<'a> {
env: Environment<'a>,
path: PathBuf,
}
impl<'a> PathEnvironment<'a> {
pub fn new(path: &Path) -> Self {
let mut env = Environment::new();
// Configure jinja
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.set_auto_escape_callback(|_| AutoEscape::None);
// Add filters
env.add_filter("to_prefix", |netmask: String| {
let netmask = Ipv4Addr::from_str(&netmask).map_err(|err| {
minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, err.to_string())
})?;
let mask = netmask.to_bits();
let prefix = mask.leading_ones();
if mask.checked_shl(prefix).unwrap_or(0) == 0 {
Ok(prefix as u8)
} else {
Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"invalid IP prefix length",
))
}
});
// Add path loader
env.set_loader(path_loader(&path));
Self {
path: path.into(),
env,
}
}
pub fn new_patches() -> Self {
Self::new(&get_talos_config_path().join("patches"))
}
pub fn new_templates(repo: &Path) -> Self {
let mut env = Self::new(&get_repo_path().join("templates"));
let path = repo.absolute().unwrap();
env.env.add_filter("kubeconfig", move |names: Vec<String>| {
names
.iter()
.map(|name| {
path.join("configs")
.join(name)
.join("kubeconfig")
.to_string_lossy()
.to_string()
})
.collect::<Vec<_>>()
});
env
}
}
// Make PathEnvironment act like a normal Environment transparently
impl<'a> Deref for PathEnvironment<'a> {
type Target = Environment<'a>;
fn deref(&self) -> &Self::Target {
&self.env
}
}
// Iterate over all the files in the path
impl<'a, 'b> IntoIterator for &'b PathEnvironment<'a> {
type Item = String;
type IntoIter = impl Iterator<Item = Self::Item>;
fn into_iter(self) -> Self::IntoIter {
// Find all templates in path
WalkDir::new(&self.path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.metadata().unwrap().is_file())
.map(|e| {
e.path()
.strip_prefix(&self.path)
.expect("All paths should have prefix")
.to_string_lossy()
.to_string()
})
}
}
+26
View File
@@ -0,0 +1,26 @@
#![feature(impl_trait_in_assoc_type, path_absolute_method)]
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
pub mod cluster;
pub mod environment;
pub mod node;
pub mod patch;
pub mod schematic;
pub mod secret;
pub(crate) static REPO_PATH: OnceLock<PathBuf> = OnceLock::new();
pub fn set_repo_path(path: impl Into<PathBuf>) {
REPO_PATH
.set(path.into())
.expect("Repo path already initialized");
}
fn get_repo_path() -> &'static Path {
REPO_PATH.get().expect("Repo path not initialized")
}
fn get_talos_config_path() -> PathBuf {
get_repo_path().join("talos")
}
+51
View File
@@ -0,0 +1,51 @@
mod cli;
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate as generate_complete};
use crete::cluster::get_clusters;
use crete::environment::PathEnvironment;
use crete::set_repo_path;
use minijinja::context;
use crate::cli::{Cli, Commands, GlobalOpts};
fn generate(opts: &GlobalOpts) {
set_repo_path(&opts.repo);
let patch_env = PathEnvironment::new_patches();
let clusters = get_clusters(&patch_env);
let path = opts.repo.join("rendered");
if path.exists() {
std::fs::remove_dir_all(&path).unwrap();
}
std::fs::create_dir(&path).unwrap();
let template_env = PathEnvironment::new_templates(&opts.repo);
for template_name in &template_env {
let template = template_env.get_template(&template_name).unwrap();
let content = template
.render(context! {
clusters,
root => opts.repo
})
.unwrap();
std::fs::write(path.join(template_name), content).unwrap();
}
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Generate => generate(&cli.global_opts),
Commands::ShellCompletions => generate_complete(
Shell::from_env().unwrap_or(Shell::Bash),
&mut Cli::command(),
"crete",
&mut std::io::stdout(),
),
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::net::Ipv4Addr;
use minijinja::Environment;
use optional_struct::{Applicable, optional_struct};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::cluster::Cluster;
use crate::get_talos_config_path;
use crate::patch::{OptionalPatches, Patches};
use crate::schematic::Schematic;
use crate::secret::Secret;
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum NodeType {
Worker,
#[serde(rename(serialize = "controlplane"))]
ControlPlane,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum NodeArch {
Amd64,
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Tailscale {
auth_key: Secret,
advertise_routes: bool,
#[serde(default)]
server: Option<String>,
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Network {
interface: String,
ip: Ipv4Addr,
netmask: Ipv4Addr,
gateway: Ipv4Addr,
dns: [Ipv4Addr; 2],
#[optional_rename(OptionalTailscale)]
#[optional_wrap]
tailscale: Tailscale,
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Install {
auto: bool,
disk: String,
serial: Option<String>,
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Node {
#[serde(skip_deserializing)]
hostname: String,
arch: NodeArch,
schematic: Schematic,
r#type: NodeType,
#[optional_rename(OptionalNetwork)]
#[optional_wrap]
network: Network,
ntp: String,
#[optional_rename(OptionalInstall)]
#[optional_wrap]
install: Install,
kernel_args: Vec<String>,
#[optional_rename(OptionalPatches)]
#[optional_wrap]
pub(crate) patches: Patches,
sops: Secret,
}
impl Node {
pub fn get(node_name: &str, env: &Environment, cluster: &Cluster) -> Self {
let mut path = get_talos_config_path().join("nodes").join(node_name);
let named = OptionalNode {
hostname: Some(
path.file_name()
.expect("Path should be valid")
.to_string_lossy()
.to_string(),
),
..OptionalNode::default()
};
path.add_extension("yaml");
let content = std::fs::read_to_string(path).unwrap();
let node: OptionalNode = serde_yaml::from_str(&content).unwrap();
// We want all vectors to be empty vectors by default
// Sadly we have to this manually
// TODO: Find a better way of doing this
let default = OptionalNode {
patches: Some(OptionalPatches {
all: Some(vec![]),
control_plane: Some(vec![]),
}),
kernel_args: vec![].into(),
..Default::default()
};
// Combine all the optional node parts into complete struct
let mut node: Node = default
// Apply cluster default settings
.apply(cluster.default.clone())
// Apply hostname based on filename
.apply(named)
// Override node specific settings
.apply(node)
.try_into()
.unwrap();
// Prepend the cluster base values
let mut kernel_args = cluster.base.kernel_args.clone();
kernel_args.extend(node.kernel_args);
node.kernel_args = kernel_args;
let patches = cluster.base.patches.clone().extend(node.patches);
node.patches = patches;
// Render patches
node.patches = node.patches.clone().render(env, cluster, &node);
node
}
}
+65
View File
@@ -0,0 +1,65 @@
use minijinja::{Environment, context};
use optional_struct::optional_struct;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::cluster::Cluster;
use crate::node::Node;
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub(crate) enum Patch {
Name(String),
#[serde(skip_deserializing)]
#[schemars(with = "serde_json::Value")]
Resolved(serde_yaml::Value),
}
#[optional_struct]
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Patches {
pub(crate) all: Vec<Patch>,
pub(crate) control_plane: Vec<Patch>,
}
fn render(patches: Vec<Patch>, env: &Environment, cluster: &Cluster, node: &Node) -> Vec<Patch> {
patches
.into_iter()
.map(|patch| {
if let Patch::Name(name) = patch {
let content = env
.get_template(&name)
.unwrap()
.render(context! {
node,
cluster
})
.unwrap();
Patch::Resolved(serde_yaml::from_str(&content).unwrap())
} else {
patch
}
})
.collect()
}
impl Patches {
pub(crate) fn extend(mut self, other: Self) -> Self {
self.all.extend(other.all);
self.control_plane.extend(other.control_plane);
Self {
all: self.all,
control_plane: self.control_plane,
}
}
pub(crate) fn render(self, env: &Environment, cluster: &Cluster, node: &Node) -> Self {
Self {
all: render(self.all.clone(), env, cluster, node),
control_plane: render(self.control_plane.clone(), env, cluster, node),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use crate::get_talos_config_path;
fn deserialize_schematic<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let name: String = Deserialize::deserialize(deserializer)?;
let path = get_talos_config_path().join("schematics").join(name);
let content = std::fs::read_to_string(path).unwrap().trim().to_owned();
let client = reqwest::blocking::Client::new();
let res = client
.post("https://factory.talos.dev/schematics")
.body(content)
.send()
.map_err(serde::de::Error::custom)?;
#[derive(Debug, Deserialize)]
struct Response {
id: String,
}
let response: Response = serde_json::from_str(&res.text().map_err(serde::de::Error::custom)?)
.map_err(serde::de::Error::custom)?;
Ok(response.id)
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
pub(crate) struct Schematic(#[serde(deserialize_with = "deserialize_schematic")] String);
+34
View File
@@ -0,0 +1,34 @@
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use crate::get_talos_config_path;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", untagged)]
enum SecretHelper {
String(String),
File { file: String },
}
pub(crate) fn deserialize_secret<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let secret: SecretHelper = Deserialize::deserialize(deserializer)?;
let value = match secret {
SecretHelper::String(value) => value,
SecretHelper::File { file } => {
let path = get_talos_config_path().join("secrets").join(file);
std::fs::read_to_string(path).unwrap().trim().to_owned()
}
};
Ok(value)
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Eq)]
pub(crate) struct Secret(
#[serde(deserialize_with = "deserialize_secret")]
#[schemars(with = "SecretHelper")]
String,
);