refactor: Use askama crate for compile-time template type checking
This commit is contained in:
parent
039c5f9659
commit
d663b7574b
9 changed files with 364 additions and 778 deletions
|
|
@ -9,7 +9,10 @@ use clap::Args;
|
|||
use log::{debug, error, info, trace, warn};
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::{module_recipe::Recipe, ops, template::TemplateCommand};
|
||||
use crate::{
|
||||
ops,
|
||||
template::{Recipe, TemplateCommand},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Args, TypedBuilder)]
|
||||
pub struct BuildCommand {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@ pub mod init;
|
|||
#[cfg(feature = "build")]
|
||||
pub mod build;
|
||||
|
||||
pub mod module_recipe;
|
||||
mod ops;
|
||||
pub mod template;
|
||||
|
|
|
|||
|
|
@ -1,119 +1 @@
|
|||
use std::{collections::HashMap, env};
|
||||
|
||||
use chrono::Local;
|
||||
use log::{debug, info, trace, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Recipe {
|
||||
pub name: String,
|
||||
|
||||
pub description: String,
|
||||
|
||||
#[serde(alias = "base-image")]
|
||||
pub base_image: String,
|
||||
|
||||
#[serde(alias = "image-version")]
|
||||
pub image_version: String,
|
||||
|
||||
pub modules: Vec<Module>,
|
||||
|
||||
pub containerfiles: Option<Containerfiles>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
pub fn generate_tags(&self) -> Vec<String> {
|
||||
debug!("Generating image tags for {}", &self.name);
|
||||
trace!("Recipe::generate_tags()");
|
||||
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
let image_version = &self.image_version;
|
||||
let timestamp = Local::now().format("%Y%m%d").to_string();
|
||||
|
||||
if let (Ok(commit_branch), Ok(default_branch), Ok(commit_sha), Ok(pipeline_source)) = (
|
||||
env::var("CI_COMMIT_REF_NAME"),
|
||||
env::var("CI_DEFAULT_BRANCH"),
|
||||
env::var("CI_COMMIT_SHORT_SHA"),
|
||||
env::var("CI_PIPELINE_SOURCE"),
|
||||
) {
|
||||
trace!("CI_COMMIT_REF_NAME={commit_branch}, CI_DEFAULT_BRANCH={default_branch},CI_COMMIT_SHORT_SHA={commit_sha}, CI_PIPELINE_SOURCE={pipeline_source}");
|
||||
warn!("Detected running in Gitlab, pulling information from CI variables");
|
||||
|
||||
if let Ok(mr_iid) = env::var("CI_MERGE_REQUEST_IID") {
|
||||
trace!("CI_MERGE_REQUEST_IID={mr_iid}");
|
||||
if pipeline_source == "merge_request_event" {
|
||||
debug!("Running in a MR");
|
||||
tags.push(format!("mr-{mr_iid}-{image_version}"));
|
||||
}
|
||||
}
|
||||
|
||||
if default_branch != commit_branch {
|
||||
debug!("Running on branch {commit_branch}");
|
||||
tags.push(format!("{commit_branch}-{image_version}"));
|
||||
} else {
|
||||
debug!("Running on the default branch");
|
||||
tags.push(image_version.to_string());
|
||||
tags.push(format!("{image_version}-{timestamp}"));
|
||||
tags.push(timestamp.to_string());
|
||||
}
|
||||
|
||||
tags.push(format!("{commit_sha}-{image_version}"));
|
||||
} else if let (
|
||||
Ok(github_event_name),
|
||||
Ok(github_event_number),
|
||||
Ok(github_sha),
|
||||
Ok(github_ref_name),
|
||||
) = (
|
||||
env::var("GITHUB_EVENT_NAME"),
|
||||
env::var("PR_EVENT_NUMBER"),
|
||||
env::var("GITHUB_SHA"),
|
||||
env::var("GITHUB_REF_NAME"),
|
||||
) {
|
||||
trace!("GITHUB_EVENT_NAME={github_event_name},PR_EVENT_NUMBER={github_event_number},GITHUB_SHA={github_sha},GITHUB_REF_NAME={github_ref_name}");
|
||||
warn!("Detected running in Github, pulling information from GITHUB variables");
|
||||
|
||||
let mut short_sha = github_sha.clone();
|
||||
short_sha.truncate(7);
|
||||
|
||||
if github_event_name == "pull_request" {
|
||||
debug!("Running in a PR");
|
||||
tags.push(format!("pr-{github_event_number}-{image_version}"));
|
||||
} else if github_ref_name == "live" {
|
||||
tags.push(image_version.to_owned());
|
||||
tags.push(format!("{image_version}-{timestamp}"));
|
||||
tags.push("latest".to_string());
|
||||
} else {
|
||||
tags.push(format!("br-{github_ref_name}-{image_version}"));
|
||||
}
|
||||
tags.push(format!("{short_sha}-{image_version}"));
|
||||
} else {
|
||||
warn!("Running locally");
|
||||
tags.push(format!("{image_version}-local"));
|
||||
}
|
||||
info!("Finished generating tags!");
|
||||
debug!("Tags: {tags:#?}");
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Module {
|
||||
#[serde(rename = "type")]
|
||||
pub module_type: Option<String>,
|
||||
|
||||
#[serde(rename = "from-file")]
|
||||
pub from_file: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub config: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Containerfiles {
|
||||
pub pre: Option<Vec<String>>,
|
||||
pub post: Option<Vec<String>>,
|
||||
}
|
||||
|
|
|
|||
352
src/template.rs
352
src/template.rs
|
|
@ -6,15 +6,139 @@ use std::{
|
|||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use askama::Template;
|
||||
use chrono::Local;
|
||||
use clap::Args;
|
||||
use log::{debug, error, info, trace};
|
||||
use tera::{Context, Tera};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::module_recipe::Recipe;
|
||||
#[derive(Debug, Clone, Template, TypedBuilder)]
|
||||
#[template(path = "Containerfile")]
|
||||
pub struct ContainerFileTemplate<'a> {
|
||||
recipe: &'a Recipe,
|
||||
recipe_path: &'a Path,
|
||||
|
||||
pub const DEFAULT_CONTAINERFILE: &str = include_str!("../templates/Containerfile.tera");
|
||||
pub const EXPORT_SCRIPT: &str = include_str!("../templates/export.sh");
|
||||
#[builder(default)]
|
||||
export_script: ExportsTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Template)]
|
||||
#[template(path = "export.sh", escape = "none")]
|
||||
pub struct ExportsTemplate;
|
||||
|
||||
#[derive(Serialize, Clone, Deserialize, Debug)]
|
||||
pub struct Recipe {
|
||||
pub name: String,
|
||||
|
||||
pub description: String,
|
||||
|
||||
#[serde(alias = "base-image")]
|
||||
pub base_image: String,
|
||||
|
||||
#[serde(alias = "image-version")]
|
||||
pub image_version: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub modules_ext: ModuleExt,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
pub fn generate_tags(&self) -> Vec<String> {
|
||||
debug!("Generating image tags for {}", &self.name);
|
||||
trace!("Recipe::generate_tags()");
|
||||
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
let image_version = &self.image_version;
|
||||
let timestamp = Local::now().format("%Y%m%d").to_string();
|
||||
|
||||
if let (Ok(commit_branch), Ok(default_branch), Ok(commit_sha), Ok(pipeline_source)) = (
|
||||
env::var("CI_COMMIT_REF_NAME"),
|
||||
env::var("CI_DEFAULT_BRANCH"),
|
||||
env::var("CI_COMMIT_SHORT_SHA"),
|
||||
env::var("CI_PIPELINE_SOURCE"),
|
||||
) {
|
||||
trace!("CI_COMMIT_REF_NAME={commit_branch}, CI_DEFAULT_BRANCH={default_branch},CI_COMMIT_SHORT_SHA={commit_sha}, CI_PIPELINE_SOURCE={pipeline_source}");
|
||||
warn!("Detected running in Gitlab, pulling information from CI variables");
|
||||
|
||||
if let Ok(mr_iid) = env::var("CI_MERGE_REQUEST_IID") {
|
||||
trace!("CI_MERGE_REQUEST_IID={mr_iid}");
|
||||
if pipeline_source == "merge_request_event" {
|
||||
debug!("Running in a MR");
|
||||
tags.push(format!("mr-{mr_iid}-{image_version}"));
|
||||
}
|
||||
}
|
||||
|
||||
if default_branch != commit_branch {
|
||||
debug!("Running on branch {commit_branch}");
|
||||
tags.push(format!("{commit_branch}-{image_version}"));
|
||||
} else {
|
||||
debug!("Running on the default branch");
|
||||
tags.push(image_version.to_string());
|
||||
tags.push(format!("{image_version}-{timestamp}"));
|
||||
tags.push(timestamp.to_string());
|
||||
}
|
||||
|
||||
tags.push(format!("{commit_sha}-{image_version}"));
|
||||
} else if let (
|
||||
Ok(github_event_name),
|
||||
Ok(github_event_number),
|
||||
Ok(github_sha),
|
||||
Ok(github_ref_name),
|
||||
) = (
|
||||
env::var("GITHUB_EVENT_NAME"),
|
||||
env::var("PR_EVENT_NUMBER"),
|
||||
env::var("GITHUB_SHA"),
|
||||
env::var("GITHUB_REF_NAME"),
|
||||
) {
|
||||
trace!("GITHUB_EVENT_NAME={github_event_name},PR_EVENT_NUMBER={github_event_number},GITHUB_SHA={github_sha},GITHUB_REF_NAME={github_ref_name}");
|
||||
warn!("Detected running in Github, pulling information from GITHUB variables");
|
||||
|
||||
let mut short_sha = github_sha.clone();
|
||||
short_sha.truncate(7);
|
||||
|
||||
if github_event_name == "pull_request" {
|
||||
debug!("Running in a PR");
|
||||
tags.push(format!("pr-{github_event_number}-{image_version}"));
|
||||
} else if github_ref_name == "live" {
|
||||
tags.push(image_version.to_owned());
|
||||
tags.push(format!("{image_version}-{timestamp}"));
|
||||
tags.push("latest".to_string());
|
||||
} else {
|
||||
tags.push(format!("br-{github_ref_name}-{image_version}"));
|
||||
}
|
||||
tags.push(format!("{short_sha}-{image_version}"));
|
||||
} else {
|
||||
warn!("Running locally");
|
||||
tags.push(format!("{image_version}-local"));
|
||||
}
|
||||
info!("Finished generating tags!");
|
||||
debug!("Tags: {tags:#?}");
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Deserialize, Debug, Template)]
|
||||
#[template(path = "Containerfile.module", escape = "none")]
|
||||
pub struct ModuleExt {
|
||||
pub modules: Vec<Module>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Module {
|
||||
#[serde(rename = "type")]
|
||||
pub module_type: Option<String>,
|
||||
|
||||
#[serde(rename = "from-file")]
|
||||
pub from_file: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub config: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, TypedBuilder)]
|
||||
pub struct TemplateCommand {
|
||||
|
|
@ -50,14 +174,16 @@ impl TemplateCommand {
|
|||
fn template_file(&self) -> Result<()> {
|
||||
trace!("TemplateCommand::template_file()");
|
||||
|
||||
debug!("Setting up tera");
|
||||
let (tera, context) = self.setup_tera()?;
|
||||
debug!("Deserializing recipe");
|
||||
let recipe_de = serde_yaml::from_str::<Recipe>(fs::read_to_string(&self.recipe)?.as_str())?;
|
||||
trace!("recipe_de: {recipe_de:#?}");
|
||||
|
||||
trace!("tera: {tera:#?}");
|
||||
trace!("context: {context:#?}");
|
||||
let template = ContainerFileTemplate::builder()
|
||||
.recipe(&recipe_de)
|
||||
.recipe_path(&self.recipe)
|
||||
.build();
|
||||
|
||||
debug!("Rendering Containerfile");
|
||||
let output_str = tera.render("Containerfile", &context)?;
|
||||
let output_str = template.render()?;
|
||||
|
||||
match self.output.as_ref() {
|
||||
Some(output) => {
|
||||
|
|
@ -75,137 +201,83 @@ impl TemplateCommand {
|
|||
info!("Finished templating Containerfile");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_tera(&self) -> Result<(Tera, Context)> {
|
||||
trace!("TemplateCommand::setup_tera()");
|
||||
fn print_script(script_contents: &ExportsTemplate) -> String {
|
||||
trace!("print_script({script_contents})");
|
||||
|
||||
debug!("Deserializing recipe");
|
||||
let recipe_de = serde_yaml::from_str::<Recipe>(fs::read_to_string(&self.recipe)?.as_str())?;
|
||||
trace!("recipe_de: {recipe_de:#?}");
|
||||
format!(
|
||||
"\"{}\"",
|
||||
script_contents
|
||||
.render()
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Failed to render export.sh script: {e}");
|
||||
process::exit(1);
|
||||
})
|
||||
.replace('\n', "\\n")
|
||||
.replace('\"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
)
|
||||
}
|
||||
|
||||
debug!("Building context");
|
||||
let mut context = Context::from_serialize(recipe_de)?;
|
||||
|
||||
trace!("add to context 'recipe': {}", self.recipe.display());
|
||||
context.insert("recipe", &self.recipe);
|
||||
|
||||
let mut tera = Tera::default();
|
||||
|
||||
match self.containerfile.as_ref() {
|
||||
Some(containerfile) => {
|
||||
debug!("Using {} as the template", containerfile.display());
|
||||
tera.add_raw_template("Containerfile", &fs::read_to_string(containerfile)?)?
|
||||
}
|
||||
None => tera.add_raw_template("Containerfile", DEFAULT_CONTAINERFILE)?,
|
||||
}
|
||||
|
||||
debug!("Registering function `print_containerfile`");
|
||||
tera.register_function(
|
||||
"print_containerfile",
|
||||
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
trace!("tera fn print_containerfile({args:#?})");
|
||||
match args.get("containerfile") {
|
||||
Some(v) => match v.as_str() {
|
||||
Some(containerfile) => {
|
||||
debug!("Loading containerfile contents for {containerfile}");
|
||||
|
||||
let path =
|
||||
format!("config/containerfiles/{containerfile}/Containerfile");
|
||||
let path = Path::new(path.as_str());
|
||||
|
||||
let file = fs::read_to_string(path)?;
|
||||
|
||||
trace!("Containerfile contents {}:\n{file}", path.display());
|
||||
Ok(file.into())
|
||||
}
|
||||
None => Err("Arg containerfile wasn't a string".into()),
|
||||
},
|
||||
None => {
|
||||
Err("Needs the argument 'containerfile' for print_containerfile()".into())
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debug!("Registering function `print_module_context`");
|
||||
tera.register_function(
|
||||
"print_module_context",
|
||||
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
trace!("tera fn print_module_context({args:#?})");
|
||||
match args.get("module") {
|
||||
Some(v) => match serde_json::to_string(v) {
|
||||
Ok(s) => Ok(s.into()),
|
||||
Err(e) => Err(format!("Unable to serialize: {e}").into()),
|
||||
},
|
||||
None => Err("Needs the argument 'module' for print_module_context()".into()),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debug!("Registering function `get_module_from_file`");
|
||||
tera.register_function(
|
||||
"get_module_from_file",
|
||||
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
trace!("tera fn get_module_from_file({args:#?})");
|
||||
match args.get("file") {
|
||||
Some(v) => {
|
||||
let file = match v.as_str() {
|
||||
Some(s) => s,
|
||||
None => return Err("Property 'from-file' must be a string".into()),
|
||||
};
|
||||
|
||||
trace!("from-file: {file}");
|
||||
match serde_yaml::from_str::<tera::Value>(
|
||||
fs::read_to_string(format!("config/{file}"))?.as_str(),
|
||||
) {
|
||||
Ok(context) => {
|
||||
trace!("context: {context}");
|
||||
Ok(context)
|
||||
}
|
||||
Err(_) => Err(format!("Unable to deserialize file {file}").into()),
|
||||
}
|
||||
}
|
||||
None => Err("Needs the argument 'file' for get_module_from_file()".into()),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debug!("Registering function `running_gitlab_actions`");
|
||||
tera.register_function(
|
||||
"running_gitlab_actions",
|
||||
|_: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
trace!("tera fn running_gitlab_actions()");
|
||||
|
||||
Ok(env::var("GITHUB_ACTIONS").is_ok_and(|e| e == "true").into())
|
||||
},
|
||||
);
|
||||
|
||||
debug!("Registering function `print_script`");
|
||||
tera.register_function(
|
||||
"print_script",
|
||||
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
trace!("tera fn print_script({args:#?})");
|
||||
|
||||
let escape_script = |script_contents: &str| {
|
||||
format!(
|
||||
"\"{}\"",
|
||||
script_contents
|
||||
.replace('\n', "\\n")
|
||||
.replace('\"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
)
|
||||
};
|
||||
|
||||
match args.get("script") {
|
||||
Some(x) => match x.as_str().unwrap_or_default() {
|
||||
"export" => Ok(escape_script(EXPORT_SCRIPT).into()),
|
||||
_ => Err(format!("Script {x} doesn't exist").into()),
|
||||
},
|
||||
None => Err("Needs the argument 'script' for 'print_script()'".into()),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Ok((tera, context))
|
||||
fn get_containerfile_list(module: &Module) -> Option<Vec<String>> {
|
||||
if module.module_type.as_ref()? == "containerfile" {
|
||||
Some(
|
||||
module
|
||||
.config
|
||||
.get("containerfiles")?
|
||||
.as_sequence()?
|
||||
.iter()
|
||||
.filter_map(|t| Some(t.as_str()?.to_owned()))
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn print_containerfile(containerfile: &str) -> String {
|
||||
trace!("print_containerfile({containerfile})");
|
||||
debug!("Loading containerfile contents for {containerfile}");
|
||||
|
||||
let path = format!("config/containerfiles/{containerfile}/Containerfile");
|
||||
|
||||
let file = fs::read_to_string(&path).unwrap_or_else(|e| {
|
||||
error!("Failed to read file {path}: {e}");
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
trace!("Containerfile contents {path}:\n{file}");
|
||||
|
||||
file
|
||||
}
|
||||
|
||||
fn get_module_from_file(file: &str) -> ModuleExt {
|
||||
trace!("get_module_from_file({file})");
|
||||
|
||||
serde_yaml::from_str(
|
||||
fs::read_to_string(format!("config/{file}").as_str())
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Failed to read module {file}: {e}");
|
||||
process::exit(1);
|
||||
})
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Failed to parse {file}: {e}");
|
||||
process::exit(1);
|
||||
})
|
||||
}
|
||||
|
||||
fn print_module_context(module: &Module) -> String {
|
||||
serde_json::to_string(module).unwrap_or_else(|e| {
|
||||
error!("Failed to parse module: {e}");
|
||||
process::exit(1);
|
||||
})
|
||||
}
|
||||
|
||||
fn running_gitlab_actions() -> bool {
|
||||
trace!(" running_gitlab_actions()");
|
||||
|
||||
env::var("GITHUB_ACTIONS").is_ok_and(|e| e == "true")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue