feat: rename cuddle_cli -> cuddle

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2023-08-12 21:41:00 +02:00
parent 53b7513ceb
commit 2cd9509fcb
14 changed files with 3 additions and 3 deletions

42
cuddle/Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "cuddle"
description = "cuddle is a shuttle inspired script and configuration management tool. It enables sharing of workflows on developers workstations and ci"
repository = "https://git.front.kjuulh.io/kjuulh/cuddle"
readme = "../README.md"
license-file = "../LICENSE"
publish = true
version = "0.2.0"
edition = "2021"
[[bin]]
name = "cuddle"
path = "src/main.rs"
[dependencies]
anyhow = { version = "1.0.72", features = ["backtrace"] }
serde = { version = "1.0.183", features = ["derive"] }
serde_yaml = "0.9.25"
walkdir = "2.3.3"
git2 = { version = "0.17.2", default-features = false, features = [
"vendored-libgit2",
"vendored-openssl",
"ssh",
] }
clap = { version = "4.3.21", features = ["env", "string"] }
envconfig = "0.10.0"
dirs = "5.0.1"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["json", "env-filter"] }
log = { version = "0.4.20", features = ["std", "kv_unstable"] }
tera = "1.19.0"
openssl = { version = "0.10.56", features = ["vendored"] }
libz-sys = { version = "1.1.12", default-features = false, features = [
"libc",
"static",
] }
inquire = { version = "0.6.2", features = ["console"] }
tempfile = { version = "3.7.1" }
serde_json = "1.0.104"
rlua = "0.19.7"
rlua-searcher = "0.1.0"
dotenv = { version = "0.15.0", features = ["clap"] }

188
cuddle/src/actions/mod.rs Normal file
View File

@@ -0,0 +1,188 @@
use std::{collections::HashMap, path::PathBuf};
use anyhow::Context;
use clap::ArgMatches;
use rlua::Lua;
use rlua_searcher::AddSearcher;
use crate::{
actions::shell::ShellAction,
model::{CuddleScript, CuddleShellScriptArg, CuddleVariable},
};
pub mod shell;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct CuddleAction {
pub script: CuddleScript,
pub path: PathBuf,
pub description: Option<String>,
pub name: String,
}
#[allow(dead_code)]
impl CuddleAction {
pub fn new(
script: CuddleScript,
path: PathBuf,
name: String,
description: Option<String>,
) -> Self {
Self {
script,
path,
name,
description,
}
}
pub fn execute(
self,
matches: &ArgMatches,
variables: Vec<CuddleVariable>,
) -> anyhow::Result<()> {
match self.script {
CuddleScript::Shell(s) => {
let mut arg_variables: Vec<CuddleVariable> = vec![];
if let Some(args) = s.args {
for (k, v) in args {
let var = match v {
CuddleShellScriptArg::Env(e) => {
let env_var = matches.get_one::<String>(&k).cloned().ok_or(
anyhow::anyhow!(
"failed to find env variable with key: {}",
&e.key
),
)?;
CuddleVariable::new(k.clone(), env_var)
}
CuddleShellScriptArg::Flag(flag) => {
match matches.get_one::<String>(&flag.name) {
Some(flag_var) => {
CuddleVariable::new(k.clone(), flag_var.clone())
}
None => continue,
}
}
};
arg_variables.push(var);
}
} else {
arg_variables = vec![]
};
let mut vars = variables.clone();
vars.append(&mut arg_variables);
log::trace!("preparing to run action");
return match ShellAction::new(
self.name.clone(),
format!(
"{}/scripts/{}.sh",
self.path
.to_str()
.expect("action doesn't have a name, this should never happen"),
self.name
),
)
.execute(vars)
{
Ok(()) => {
log::trace!("finished running action");
Ok(())
}
Err(e) => {
log::error!("{}", e);
Err(e)
}
};
}
CuddleScript::Dagger(_d) => Err(anyhow::anyhow!("not implemented yet!")),
CuddleScript::Lua(l) => {
let lua = Lua::new();
let mut map = HashMap::new();
//map.insert("init".into(), "print(\"something\")".into());
let lua_dir = PathBuf::new().join(&self.path).join("scripts").join("lua");
if lua_dir.exists() {
let absolute_lua_dir = lua_dir.canonicalize()?;
for entry in walkdir::WalkDir::new(&lua_dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.metadata()?.is_file() {
let full_file_path = entry.path().canonicalize()?;
let relative_module_path =
full_file_path.strip_prefix(&absolute_lua_dir)?;
let module_path = relative_module_path
.to_string_lossy()
.to_string()
.trim_end_matches("/init.lua")
.trim_end_matches(".lua")
.replace("/", ".");
let contents = std::fs::read_to_string(entry.path())?;
tracing::trace!(module_path = &module_path, "adding lua file");
map.insert(module_path.into(), contents.into());
}
}
}
let lua_rocks_dir = PathBuf::new()
.join(&self.path)
.join("lua_modules")
.join("share")
.join("lua")
.join("5.4");
if lua_rocks_dir.exists() {
let absolute_lua_dir = lua_rocks_dir.canonicalize()?;
for entry in walkdir::WalkDir::new(&lua_rocks_dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.metadata()?.is_file() {
let full_file_path = entry.path().canonicalize()?;
let relative_module_path =
full_file_path.strip_prefix(&absolute_lua_dir)?;
let module_path = relative_module_path
.to_string_lossy()
.to_string()
.trim_end_matches("/init.lua")
.trim_end_matches(".lua")
.replace("/", ".");
let contents = std::fs::read_to_string(entry.path())?;
tracing::trace!(module_path = &module_path, "adding lua file");
map.insert(module_path.into(), contents.into());
}
}
}
lua.context::<_, anyhow::Result<()>>(|lua_ctx| {
lua_ctx.add_searcher(map)?;
let globals = lua_ctx.globals();
let lua_script_entry = std::fs::read_to_string(
PathBuf::new()
.join(&self.path)
.join("scripts")
.join(format!("{}.lua", &self.name)),
)
.context("failed to find lua script")?;
lua_ctx
.load(&lua_script_entry)
.set_name(&self.name)?
.exec()?;
Ok(())
})?;
Ok(())
}
}
}
}

View File

@@ -0,0 +1,62 @@
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::{env::current_dir, path::PathBuf, process::Command};
use crate::model::CuddleVariable;
#[allow(dead_code)]
pub struct ShellAction {
path: String,
name: String,
}
impl ShellAction {
pub fn new(name: String, path: String) -> Self {
Self { path, name }
}
pub fn execute(self, variables: Vec<CuddleVariable>) -> anyhow::Result<()> {
log::debug!("executing shell action: {}", self.path.clone());
log::info!("Starting running shell action: {}", self.path.clone());
let path = PathBuf::from(self.path.clone());
if !path.exists() {
log::info!("script='{}' not found, aborting", path.to_string_lossy());
return Err(anyhow::anyhow!("file not found aborting"));
}
let current_dir = current_dir()?;
log::trace!("current executable dir={}", current_dir.to_string_lossy());
let mut process = Command::new(&path)
.current_dir(current_dir)
.envs(variables.iter().map(|v| {
log::trace!("{:?}", v);
(v.name.to_uppercase(), v.value.clone())
}))
.spawn()?;
let status = process.wait()?;
match status.code() {
None => {
log::warn!("process exited without code")
}
Some(n) => {
if n > 0 {
return Err(anyhow::anyhow!(
"{} exited with: {}",
path.clone().to_string_lossy(),
n
));
}
}
}
log::info!("Finished running shell action");
Ok(())
}
}

384
cuddle/src/cli/mod.rs Normal file
View File

@@ -0,0 +1,384 @@
mod subcommands;
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use clap::Command;
use crate::{
actions::CuddleAction,
config::{CuddleConfig, CuddleFetchPolicy},
context::{CuddleContext, CuddleTreeType},
model::*,
util::git::GitCommit,
};
use self::subcommands::render_template::RenderTemplateCommand;
#[derive(Debug, Clone)]
pub struct CuddleCli {
scripts: Vec<CuddleAction>,
variables: Vec<CuddleVariable>,
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
command: Option<Command>,
tmp_dir: Option<PathBuf>,
config: CuddleConfig,
}
impl CuddleCli {
pub fn new(
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
config: CuddleConfig,
) -> anyhow::Result<CuddleCli> {
let mut cli = CuddleCli {
scripts: vec![],
variables: vec![],
context: context.clone(),
command: None,
tmp_dir: None,
config,
};
if let Ok(provider) = std::env::var("CUDDLE_SECRETS_PROVIDER") {
let provider = provider
.split(",")
.map(|p| p.to_string())
.collect::<Vec<_>>();
tracing::trace!("secrets-provider enabled, handling for each entry");
handle_providers(provider)?;
std::thread::sleep(std::time::Duration::from_millis(100));
}
match context {
Some(_) => {
tracing::debug!("build full cli");
cli = cli
.process_variables()
.process_scripts()
.process_templates()?
.build_cli();
}
None => {
tracing::debug!("build bare cli");
cli = cli.build_bare_cli();
}
}
Ok(cli)
}
fn process_variables(mut self) -> Self {
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
if let Some(variables) = ctx.plan.vars.clone() {
for (name, var) in variables {
self.variables.push(CuddleVariable::new(name, var))
}
}
if let CuddleTreeType::Root = ctx.node_type {
let mut temp_path = ctx.path.clone();
temp_path.push(".cuddle/tmp");
self.variables.push(CuddleVariable::new(
"tmp".into(),
temp_path.clone().to_string_lossy().to_string(),
));
self.tmp_dir = Some(temp_path);
}
}
}
match GitCommit::new() {
Ok(commit) => self.variables.push(CuddleVariable::new(
"commit_sha".into(),
commit.commit_sha.clone(),
)),
Err(e) => {
log::debug!("{}", e);
}
}
self
}
fn process_scripts(mut self) -> Self {
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
if let Some(scripts) = ctx.plan.scripts.clone() {
for (name, script) in scripts {
match &script {
CuddleScript::Shell(shell_script) => {
self.scripts.push(CuddleAction::new(
script.clone(),
ctx.path.clone(),
name,
shell_script.description.clone(),
))
}
CuddleScript::Dagger(_) => todo!(),
CuddleScript::Lua(l) => self.scripts.push(CuddleAction::new(
script.clone(),
ctx.path.clone(),
name,
l.description.clone(),
)),
}
}
}
}
}
self
}
fn process_templates(self) -> anyhow::Result<Self> {
if let None = self.tmp_dir {
log::debug!("cannot process template as bare bones cli");
return Ok(self);
}
// Make sure tmp_dir exists and clean it up first
let tmp_dir = self
.tmp_dir
.clone()
.ok_or(anyhow::anyhow!("tmp_dir does not exist aborting"))?;
match self.config.get_fetch_policy()? {
CuddleFetchPolicy::Always => {
if tmp_dir.exists() && tmp_dir.ends_with("tmp") {
std::fs::remove_dir_all(tmp_dir.clone())?;
}
}
_ => {}
}
std::fs::create_dir_all(tmp_dir.clone())?;
// Handle all templating with variables and such.
// TODO: use actual templating engine, for new we just copy templates to the final folder
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
for ctx in context_iter.iter() {
let mut template_path = ctx.path.clone();
template_path.push("templates");
log::trace!("template path: {}", template_path.clone().to_string_lossy());
if !template_path.exists() {
continue;
}
for file in std::fs::read_dir(template_path)?.into_iter() {
let f = file?;
let mut dest_file = tmp_dir.clone();
dest_file.push(f.file_name());
std::fs::copy(f.path(), dest_file)?;
}
}
}
Ok(self)
}
fn build_cli(mut self) -> Self {
let mut root_cmd = Command::new("cuddle")
.version("1.0")
.author("kjuulh <contact@kasperhermansen.com>")
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
.subcommand_required(true)
.arg_required_else_help(true)
.propagate_version(true)
.arg(clap::Arg::new("secrets-provider").long("secrets-provider").env("CUDDLE_SECRETS_PROVIDER"));
root_cmd = subcommands::x::build_command(root_cmd, self.clone());
root_cmd = subcommands::render_template::build_command(root_cmd);
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
self.command = Some(root_cmd);
self
}
fn build_bare_cli(mut self) -> Self {
let mut root_cmd = Command::new("cuddle")
.version("1.0")
.author("kjuulh <contact@kasperhermansen.com>")
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
.subcommand_required(true)
.arg_required_else_help(true)
.propagate_version(true);
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
self.command = Some(root_cmd);
self
}
pub fn execute(self) -> anyhow::Result<Self> {
if let Some(cli) = self.command.clone() {
let matches = cli.clone().get_matches();
let res = match matches.subcommand() {
Some(("x", exe_submatch)) => subcommands::x::execute_x(exe_submatch, self.clone()),
Some(("render_template", sub_matches)) => {
RenderTemplateCommand::from_matches(sub_matches, self.clone())
.and_then(|cmd| cmd.execute())?;
Ok(())
}
Some(("init", sub_matches)) => {
subcommands::init::execute_init(sub_matches, self.clone())
}
_ => Err(anyhow::anyhow!("could not find a match")),
};
match res {
Ok(()) => {}
Err(e) => {
return Err(e);
}
}
}
Ok(self)
}
}
pub enum SecretProvider {
OnePassword {
inject: Vec<String>,
dotenv: Option<String>,
},
}
impl TryFrom<String> for SecretProvider {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"1password" => {
let one_password_inject = std::env::var("CUDDLE_ONE_PASSWORD_INJECT")
.ok()
.filter(|f| f.as_str() != "");
let one_password_dot_env = std::env::var("CUDDLE_ONE_PASSWORD_DOT_ENV").ok();
let injectables = one_password_inject
.unwrap_or(String::new())
.split(",")
.filter(|s| s.contains('='))
.map(|i| i.to_string())
.collect::<Vec<_>>();
// for i in &injectables {
// if !std::path::PathBuf::from(i).exists() {
// anyhow::bail!("1pass injectable path doesn't exist: {}", i);
// }
// }
if let Some(one_password_dot_env) = &one_password_dot_env {
if let Ok(dir) = std::env::current_dir() {
tracing::trace!(
current_dir = dir.display().to_string(),
dotenv = &one_password_dot_env,
exists = PathBuf::from(&one_password_dot_env).exists(),
"1password dotenv inject"
);
}
}
Ok(Self::OnePassword {
inject: injectables,
dotenv: if let Some(one_password_dot_env) = one_password_dot_env {
if PathBuf::from(&one_password_dot_env).exists() {
Some(one_password_dot_env)
} else {
None
}
} else {
None
},
})
}
value => {
tracing::debug!(
"provided secrets manager doesn't match any allowed values {}",
value
);
Err(anyhow::anyhow!("value is not one of supported values"))
}
}
}
}
fn handle_providers(provider: Vec<String>) -> anyhow::Result<()> {
fn execute_1password(lookup: &str) -> anyhow::Result<String> {
let out = std::process::Command::new("op")
.arg("read")
.arg(lookup)
.output()?;
let secret = std::str::from_utf8(&out.stdout)?;
Ok(secret.to_string())
}
fn execute_1password_inject(file: &str) -> anyhow::Result<Vec<(String, String)>> {
let out = std::process::Command::new("op")
.arg("inject")
.arg("--in-file")
.arg(file)
.output()?;
let secrets = std::str::from_utf8(&out.stdout)?.split('\n');
let secrets_pair = secrets
.map(|secrets_pair| secrets_pair.split_once("="))
.flatten()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<(String, String)>>();
Ok(secrets_pair)
}
let res = provider
.into_iter()
.map(|p| SecretProvider::try_from(p))
.collect::<anyhow::Result<Vec<_>>>();
let res = res?;
let res = res
.into_iter()
.map(|p| match p {
SecretProvider::OnePassword { inject, dotenv } => {
tracing::trace!(
inject = inject.join(","),
dotenv = dotenv,
"handling 1password"
);
if let Some(dotenv) = dotenv {
let pairs = execute_1password_inject(&dotenv).unwrap();
for (key, value) in pairs {
tracing::debug!(env_name = &key, value=&value, "set var from 1password");
std::env::set_var(key, value);
}
}
for i in inject {
let (env_var_name, op_lookup) = i.split_once("=").ok_or(anyhow::anyhow!(
"CUDDLE_ONE_PASSWORD_INJECT is not a key value pair ie. key:value,key2=value2"
))?;
let secret = execute_1password(&op_lookup)?;
std::env::set_var(&env_var_name, secret);
tracing::debug!(
env_name = &env_var_name,
lookup = &op_lookup,
"set var from 1password"
);
}
Ok(())
}
})
.collect::<anyhow::Result<Vec<()>>>();
let _ = res?;
Ok(())
}

View File

@@ -0,0 +1,218 @@
use std::collections::BTreeMap;
use std::fs::{create_dir_all, read, read_dir};
use std::io::Write;
use std::path::PathBuf;
use clap::{ArgMatches, Command};
use walkdir::WalkDir;
use crate::cli::CuddleCli;
pub fn build_command(root_cmd: Command, _cli: CuddleCli) -> Command {
let mut repo_url = clap::Arg::new("repo").long("repo").short('r');
if let Ok(cuddle_template_url) = std::env::var("CUDDLE_TEMPLATE_URL") {
repo_url = repo_url.default_value(cuddle_template_url);
} else {
repo_url = repo_url.required(true);
}
let execute_cmd = Command::new("init")
.about("init bootstraps a repository from a template")
.arg(repo_url)
.arg(clap::Arg::new("name").long("name"))
.arg(clap::Arg::new("path").long("path"))
.arg(clap::Arg::new("value").short('v').long("value"));
root_cmd.subcommand(execute_cmd)
}
pub fn execute_init(exe_submatch: &ArgMatches, _cli: CuddleCli) -> anyhow::Result<()> {
let repo = exe_submatch.get_one::<String>("repo").unwrap();
let name = exe_submatch.get_one::<String>("name");
let path = exe_submatch.get_one::<String>("path");
let values = exe_submatch
.get_many::<String>("value")
.unwrap_or_default()
.collect::<Vec<_>>();
tracing::info!("Downloading: {}", repo);
create_dir_all(std::env::temp_dir())?;
let tmpdir = tempfile::tempdir()?;
let tmpdir_path = tmpdir.path().canonicalize()?;
let output = std::process::Command::new("git")
.args(&["clone", repo, "."])
.current_dir(tmpdir_path)
.output()?;
std::io::stdout().write_all(&output.stdout)?;
std::io::stderr().write_all(&output.stderr)?;
let templates_path = tmpdir.path().join("cuddle-templates.json");
let template_path = tmpdir.path().join("cuddle-template.json");
let templates = if templates_path.exists() {
let templates = read(templates_path)?;
let templates: CuddleTemplates = serde_json::from_slice(&templates)?;
let mut single_templates = Vec::new();
for template_name in templates.templates.iter() {
let template = read(
tmpdir
.path()
.join(template_name)
.join("cuddle-template.json"),
)?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
single_templates.push((template_name, template))
}
single_templates
.into_iter()
.map(|(name, template)| (name.clone(), tmpdir.path().join(name), template))
.collect::<Vec<_>>()
} else if template_path.exists() {
let template = read(template_path)?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
vec![(template.clone().name, tmpdir.path().to_path_buf(), template)]
} else {
anyhow::bail!("No cuddle-template.json or cuddle-templates.json found");
};
let template = match name {
Some(name) => {
let template = read(tmpdir.path().join(name).join("cuddle-template.json"))?;
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
Ok((name.clone(), tmpdir.path().join(name), template))
}
None => {
if templates.len() > 1 {
let name = inquire::Select::new(
"template",
templates.iter().map(|t| t.0.clone()).collect(),
)
.with_help_message("name of which template to use")
.prompt()?;
let found_template = templates
.iter()
.find(|item| item.0 == name)
.ok_or(anyhow::anyhow!("could not find an item with that name"))?;
Ok(found_template.clone())
} else if templates.len() == 1 {
Ok(templates[0].clone())
} else {
Err(anyhow::anyhow!("No templates found, with any valid names"))
}
}
};
let (_name, template_dir, mut template) = template?;
let path = match path {
Some(path) => path.clone(),
None => inquire::Text::new("path")
.with_help_message("to where it should be placed")
.with_default(".")
.prompt()?,
};
create_dir_all(&path)?;
let dir = std::fs::read_dir(&path)?;
if dir.count() != 0 {
for entry in read_dir(&path)? {
let entry = entry?;
if entry.file_name() == ".git" {
continue;
} else {
anyhow::bail!("Directory {} is not empty", &path);
}
}
}
{
if let Some(ref mut prompt) = template.prompt {
'prompt: for (name, prompt) in prompt {
for value in &values {
if let Some((value_name, value_content)) = value.split_once("=") {
if value_name == name {
prompt.value = value_content.to_string();
continue 'prompt;
}
}
}
let value = inquire::Text::new(&name)
.with_help_message(&prompt.description)
.prompt()?;
prompt.value = value;
}
}
}
for entry in WalkDir::new(&template_dir).follow_links(false) {
let entry = entry?;
let entry_path = entry.path();
let new_path = PathBuf::from(&path).join(entry_path.strip_prefix(&template_dir)?);
let new_path = replace_with_variables(&new_path.to_string_lossy().to_string(), &template)?;
let new_path = PathBuf::from(new_path);
if entry_path.is_dir() {
create_dir_all(&new_path)?;
}
if entry_path.is_file() {
let name = entry.file_name();
if let Some(parent) = entry_path.parent() {
create_dir_all(parent)?;
}
if name == "cuddle-template.json" || name == "cuddle-templates.json" {
continue;
}
tracing::info!("writing to: {}", new_path.display());
let new_content =
replace_with_variables(&std::fs::read_to_string(entry_path)?, &template)?;
std::fs::write(new_path, new_content.as_bytes())?;
}
}
Ok(())
}
fn replace_with_variables(content: &str, template: &CuddleTemplate) -> anyhow::Result<String> {
let mut content = content.to_string();
if let Some(prompt) = &template.prompt {
for (name, value) in prompt {
content = content.replace(&format!("%%{}%%", name), &value.value);
}
}
Ok(content)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplates {
pub templates: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplate {
pub name: String,
pub prompt: Option<BTreeMap<String, CuddleTemplatePrompt>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CuddleTemplatePrompt {
pub description: String,
#[serde(skip)]
pub value: String,
}

View File

@@ -0,0 +1,3 @@
pub mod init;
pub mod render_template;
pub mod x;

View File

@@ -0,0 +1,150 @@
use std::{path::PathBuf, str::FromStr};
use clap::{Arg, ArgMatches, Command};
use crate::{cli::CuddleCli, model::CuddleVariable};
pub fn build_command(root_cmd: Command) -> Command {
root_cmd.subcommand(
Command::new("render_template")
.about("renders a jinja compatible template")
.args(&[
Arg::new("template-file")
.alias("template")
.short('t')
.long("template-file")
.required(true)
.action(clap::ArgAction::Set).long_help("template-file is the input file path of the .tmpl file (or inferred) that you would like to render"),
Arg::new("destination")
.alias("dest")
.short('d')
.long("destination")
.required(true)
.action(clap::ArgAction::Set)
.long_help("destination is the output path of the template once done, but default .tmpl is stripped and the normal file extension is used. this can be overwritten if a file path is entered instead. I.e. (/some/file/name.txt)"),
Arg::new("extra-var")
.long("extra-var")
.required(false)
.action(clap::ArgAction::Set),
]))
}
pub struct RenderTemplateCommand {
variables: Vec<CuddleVariable>,
template_file: PathBuf,
destination: PathBuf,
}
impl RenderTemplateCommand {
pub fn from_matches(matches: &ArgMatches, cli: CuddleCli) -> anyhow::Result<Self> {
let template_file = matches
.get_one::<String>("template-file")
.ok_or(anyhow::anyhow!("template-file was not found"))
.and_then(get_path_buf_and_check_exists)?;
let destination = matches
.get_one::<String>("destination")
.ok_or(anyhow::anyhow!("destination was not found"))
.and_then(get_path_buf_and_check_dir_exists)
.and_then(RenderTemplateCommand::transform_extension)?;
let mut extra_vars: Vec<CuddleVariable> =
if let Some(extra_vars) = matches.get_many::<String>("extra-var") {
let mut vars = Vec::with_capacity(extra_vars.len());
for var in extra_vars.into_iter() {
let parts: Vec<&str> = var.split("=").collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!("extra-var: is not set correctly: {}", var));
}
vars.push(CuddleVariable::new(parts[0].into(), parts[1].into()));
}
vars
} else {
vec![]
};
extra_vars.append(&mut cli.variables.clone());
Ok(Self {
variables: extra_vars,
template_file,
destination,
})
}
pub fn execute(self) -> anyhow::Result<()> {
// Prepare context
let mut context = tera::Context::new();
for var in self.variables {
context.insert(
var.name.to_lowercase().replace(" ", "_").replace("-", "_"),
&var.value,
)
}
// Load source template
let source = std::fs::read_to_string(self.template_file)?;
let output = tera::Tera::one_off(source.as_str(), &context, false)?;
// Put template in final destination
std::fs::write(&self.destination, output)?;
log::info!(
"finished writing template to: {}",
&self.destination.to_string_lossy()
);
Ok(())
}
fn transform_extension(template_path: PathBuf) -> anyhow::Result<PathBuf> {
if template_path.is_file() {
let ext = template_path.extension().ok_or(anyhow::anyhow!(
"destination path does not have an extension"
))?;
if ext.to_string_lossy().ends_with("tmpl") {
let template_dest = template_path
.to_str()
.and_then(|s| s.strip_suffix(".tmpl"))
.ok_or(anyhow::anyhow!("string does not end in .tmpl"))?;
return PathBuf::from_str(template_dest).map_err(|e| anyhow::anyhow!(e));
}
}
Ok(template_path)
}
}
fn get_path_buf_and_check_exists(raw_path: &String) -> anyhow::Result<PathBuf> {
match PathBuf::from_str(&raw_path) {
Ok(pb) => {
if pb.exists() {
Ok(pb)
} else {
Err(anyhow::anyhow!(
"path: {}, could not be found",
pb.to_string_lossy()
))
}
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}
fn get_path_buf_and_check_dir_exists(raw_path: &String) -> anyhow::Result<PathBuf> {
match PathBuf::from_str(&raw_path) {
Ok(pb) => {
if pb.is_dir() && pb.exists() {
Ok(pb)
} else if pb.is_file() {
Ok(pb)
} else {
Ok(pb)
}
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}

View File

@@ -0,0 +1,88 @@
use clap::{Arg, ArgMatches, Command};
use crate::cli::CuddleCli;
pub fn build_command(root_cmd: Command, cli: CuddleCli) -> Command {
if cli.scripts.len() > 0 {
let execute_cmd_about = "x is your entry into your domains scripts, scripts inherited from parents will also be present here";
let mut execute_cmd = Command::new("x")
.about(execute_cmd_about)
.subcommand_required(true);
execute_cmd = execute_cmd.subcommands(&build_scripts(cli));
root_cmd.subcommand(execute_cmd)
} else {
root_cmd
}
}
pub fn build_scripts(cli: CuddleCli) -> Vec<Command> {
let mut cmds = Vec::new();
for script in cli.scripts.iter() {
let mut cmd = Command::new(&script.name);
if let Some(desc) = &script.description {
cmd = cmd.about(desc)
}
match &script.script {
crate::model::CuddleScript::Shell(shell_script) => {
if let Some(args) = &shell_script.args {
for (arg_name, arg) in args {
cmd = match arg {
crate::model::CuddleShellScriptArg::Env(arg_env) => cmd.arg(
Arg::new(arg_name.clone())
.env(arg_name.to_uppercase().replace(".", "_"))
.required(true),
),
crate::model::CuddleShellScriptArg::Flag(arg_flag) => {
let mut arg_val = Arg::new(arg_name.clone())
.env(arg_name.to_uppercase().replace(".", "_"))
.long(arg_name);
if let Some(true) = arg_flag.required {
arg_val = arg_val.required(true);
}
if let Some(def) = &arg_flag.default_value {
arg_val = arg_val.default_value(def);
}
if let Some(desc) = &arg_flag.description {
arg_val = arg_val.help(&*desc.clone().leak())
}
cmd.arg(arg_val)
}
};
}
}
}
crate::model::CuddleScript::Dagger(_) => todo!(),
crate::model::CuddleScript::Lua(l) => {}
}
cmds.push(cmd)
}
cmds
}
pub fn execute_x(exe_submatch: &ArgMatches, cli: CuddleCli) -> anyhow::Result<()> {
match exe_submatch.subcommand() {
Some((name, action_matches)) => {
log::trace!(action=name; "running action; name={}", name);
match cli.scripts.iter().find(|ele| ele.name == name) {
Some(script) => {
script
.clone()
.execute(action_matches, cli.variables.clone())?;
Ok(())
}
_ => Err(anyhow::anyhow!("could not find a match")),
}
}
_ => Err(anyhow::anyhow!("could not find a match")),
}
}

28
cuddle/src/config.rs Normal file
View File

@@ -0,0 +1,28 @@
use envconfig::Envconfig;
pub enum CuddleFetchPolicy {
Always,
Once,
Never,
}
#[derive(Envconfig, Clone, Debug)]
pub struct CuddleConfig {
#[envconfig(from = "CUDDLE_FETCH_POLICY", default = "once")]
fetch_policy: String,
}
impl CuddleConfig {
pub fn from_env() -> anyhow::Result<Self> {
CuddleConfig::init_from_env().map_err(|e| anyhow::Error::from(e))
}
pub fn get_fetch_policy(&self) -> anyhow::Result<CuddleFetchPolicy> {
match self.fetch_policy.clone().to_lowercase().as_str() {
"always" => Ok(CuddleFetchPolicy::Always),
"once" => Ok(CuddleFetchPolicy::Once),
"never" => Ok(CuddleFetchPolicy::Never),
_ => Err(anyhow::anyhow!("could not parse fetch policy")),
}
}
}

215
cuddle/src/context.rs Normal file
View File

@@ -0,0 +1,215 @@
use std::{
env::{self, current_dir},
ffi::OsStr,
io::Write,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use anyhow::Context;
use git2::{
build::{CheckoutBuilder, RepoBuilder},
FetchOptions, RemoteCallbacks,
};
use crate::{
config::{CuddleConfig, CuddleFetchPolicy},
model::{CuddleBase, CuddlePlan},
};
#[derive(Debug, Clone, PartialEq)]
pub enum CuddleTreeType {
Root,
Leaf,
}
#[derive(Debug)]
pub struct CuddleContext {
pub plan: CuddlePlan,
pub path: PathBuf,
pub node_type: CuddleTreeType,
}
pub fn extract_cuddle(
config: CuddleConfig,
) -> anyhow::Result<Option<Arc<Mutex<Vec<CuddleContext>>>>> {
let mut curr_dir = current_dir()?;
curr_dir.push(".cuddle/");
let fetch_policy = config.get_fetch_policy()?;
if let CuddleFetchPolicy::Always = fetch_policy {
if curr_dir.exists() {
if let Err(res) = std::fs::remove_dir_all(curr_dir) {
panic!("{}", res)
}
}
}
// Load main cuddle file.
let cuddle_yaml = find_root_cuddle()?;
if let None = cuddle_yaml {
return Ok(None);
}
let cuddle_yaml = cuddle_yaml.unwrap();
log::trace!(cuddle_yaml=log::as_debug!(cuddle_yaml); "Find root cuddle");
let cuddle_plan = serde_yaml::from_str::<CuddlePlan>(cuddle_yaml.as_str())?;
log::debug!(cuddle_plan=log::as_debug!(cuddle_yaml); "parse cuddle plan");
let context: Arc<Mutex<Vec<CuddleContext>>> = Arc::new(Mutex::new(Vec::new()));
context.lock().unwrap().push(CuddleContext {
plan: cuddle_plan.clone(),
path: current_dir()?,
node_type: CuddleTreeType::Root,
});
// pull parent plan and execute recursive descent
match cuddle_plan.base {
CuddleBase::Bool(true) => {
return Err(anyhow::anyhow!(
"plan cannot be enabled without specifying a plan"
))
}
CuddleBase::Bool(false) => {
log::debug!("plan is root: skipping");
}
CuddleBase::String(parent_plan) => {
let destination_path = create_cuddle_local()?;
let mut cuddle_dest = destination_path.clone();
cuddle_dest.push("base");
if !cuddle_dest.exists() {
pull_parent_cuddle_into_local(parent_plan, cuddle_dest.clone())?;
}
recurse_parent(cuddle_dest, context.clone())?;
}
}
Ok(Some(context))
}
fn create_cuddle_local() -> anyhow::Result<PathBuf> {
let mut curr_dir = current_dir()?.clone();
curr_dir.push(".cuddle/");
if curr_dir.exists() {
log::debug!(".cuddle/ already exists: skipping");
return Ok(curr_dir);
}
std::fs::create_dir(curr_dir.clone())?;
Ok(curr_dir)
}
fn create_cuddle(path: PathBuf) -> anyhow::Result<PathBuf> {
let mut curr_dir = path.clone();
curr_dir.push(".cuddle/");
if curr_dir.exists() {
log::debug!(".cuddle/ already exists: skipping");
return Ok(curr_dir);
}
std::fs::create_dir(curr_dir.clone())?;
Ok(curr_dir)
}
fn pull_parent_cuddle_into_local(
parent_cuddle: String,
destination: PathBuf,
) -> anyhow::Result<()> {
let mut rc = RemoteCallbacks::new();
rc.credentials(|_url, username_from_url, _allowed_types| {
if "true".to_string() == std::env::var("CUDDLE_SSH_AGENT").ok().unwrap_or("".into()) {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap())
} else {
git2::Cred::ssh_key(
username_from_url.unwrap(),
None,
Path::new(&format!("{}/.ssh/id_ed25519", env::var("HOME").unwrap())),
None,
)
}
});
rc.certificate_check(|_cert, _something| Ok(git2::CertificateCheckStatus::CertificateOk));
let mut fo = FetchOptions::new();
fo.remote_callbacks(rc);
let co = CheckoutBuilder::new();
RepoBuilder::new()
.fetch_options(fo)
.with_checkout(co)
.clone(&parent_cuddle, &destination)?;
log::debug!(parent_cuddle=log::as_display!(parent_cuddle); "pulled repository");
Ok(())
}
fn recurse_parent(
path: PathBuf,
context: Arc<Mutex<Vec<CuddleContext>>>,
) -> anyhow::Result<Option<()>> {
let cuddle_contents = find_cuddle(path.clone())?;
if let None = cuddle_contents {
return Ok(None);
}
let cuddle_plan = serde_yaml::from_str::<CuddlePlan>(&cuddle_contents.unwrap())?;
let ctx = context.clone();
if let Ok(mut ctxs) = ctx.lock() {
ctxs.push(CuddleContext {
plan: cuddle_plan.clone(),
path: path.clone(),
node_type: CuddleTreeType::Leaf,
});
} else {
return Err(anyhow::anyhow!("Could not acquire lock, aborting"));
}
match cuddle_plan.base {
CuddleBase::Bool(true) => {
return Err(anyhow::anyhow!(
"plan cannot be enabled without specifying a plan"
))
}
CuddleBase::Bool(false) => {
log::debug!("plan is root: finishing up");
return Ok(Some(()));
}
CuddleBase::String(parent_plan) => {
let destination_path = create_cuddle(path.clone())?;
let mut cuddle_dest = destination_path.clone();
cuddle_dest.push("base");
if !cuddle_dest.exists() {
pull_parent_cuddle_into_local(parent_plan, cuddle_dest.clone())?;
}
return recurse_parent(cuddle_dest, context.clone());
}
}
}
fn find_root_cuddle() -> anyhow::Result<Option<String>> {
// TODO: Make recursive towards root
let current_dir = env::current_dir()?;
find_cuddle(current_dir)
}
fn find_cuddle(path: PathBuf) -> anyhow::Result<Option<String>> {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
let metadata = std::fs::metadata(&path)?;
if metadata.is_file() && path.file_name().unwrap() == OsStr::new("cuddle.yaml") {
return Ok(Some(std::fs::read_to_string(path)?));
}
}
Ok(None)
}

31
cuddle/src/main.rs Normal file
View File

@@ -0,0 +1,31 @@
use config::CuddleConfig;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
mod actions;
mod cli;
mod config;
mod context;
mod model;
mod util;
fn main() -> anyhow::Result<()> {
init_logging()?;
let _ = dotenv::dotenv();
let config = CuddleConfig::from_env()?;
let context = context::extract_cuddle(config.clone())?;
_ = cli::CuddleCli::new(context, config)?.execute()?;
Ok(())
}
fn init_logging() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
Ok(())
}

79
cuddle/src/model.rs Normal file
View File

@@ -0,0 +1,79 @@
use std::collections::HashMap;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum CuddleBase {
Bool(bool),
String(String),
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddleShellScriptArgEnv {
pub key: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddleShellScriptArgFlag {
pub name: String,
pub description: Option<String>,
pub required: Option<bool>,
pub default_value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "type")]
pub enum CuddleShellScriptArg {
#[serde(alias = "env")]
Env(CuddleShellScriptArgEnv),
#[serde(alias = "flag")]
Flag(CuddleShellScriptArgFlag),
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddleShellScript {
pub description: Option<String>,
pub args: Option<HashMap<String, CuddleShellScriptArg>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddleDaggerScript {
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddleLuaScript {
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "type")]
pub enum CuddleScript {
#[serde(alias = "shell")]
Shell(CuddleShellScript),
#[serde(alias = "dagger")]
Dagger(CuddleDaggerScript),
#[serde(alias = "lua")]
Lua(CuddleLuaScript),
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CuddlePlan {
pub base: CuddleBase,
pub vars: Option<HashMap<String, String>>,
pub scripts: Option<HashMap<String, CuddleScript>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct CuddleVariable {
pub name: String,
pub value: String,
}
impl CuddleVariable {
pub fn new(name: String, value: String) -> Self {
Self { name, value }
}
}

33
cuddle/src/util/git.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::env::current_dir;
use git2::Repository;
#[derive(Debug)]
pub struct GitCommit {
pub commit_sha: String,
}
impl GitCommit {
pub fn new() -> anyhow::Result<GitCommit> {
let repo = Repository::open(current_dir().expect("having current_dir available")).map_err(
|e| {
log::debug!("{}", e);
anyhow::anyhow!("could not open repository")
},
)?;
let head_ref = repo
.head()
.map_err(|e| {
log::warn!("{}", e);
anyhow::anyhow!("could not get HEAD")
})?
.target()
.ok_or(anyhow::anyhow!(
"could not extract head -> target to commit_sha"
))?;
Ok(Self {
commit_sha: head_ref.to_string(),
})
}
}

1
cuddle/src/util/mod.rs Normal file
View File

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