use crate::{podman::Podman, system::System};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand as ClapSubcommand, ValueEnum, builder::styling};
use ipnetwork::Ipv4Network;
use log::LevelFilter;
use serde::{Deserialize, Serialize};
use std::{
fmt,
fs::{self, canonicalize, create_dir_all, read_to_string},
path::{Path, PathBuf},
};
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
impl fmt::Display for LogFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LogFormat::Text => write!(f, "text"),
LogFormat::Json => write!(f, "json"),
}
}
}
#[derive(Clone, Parser, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
#[command(
after_help = "More info at: https://github.com/saschagrunert/kubernix",
author = "Sascha Grunert <mail@saschagrunert.de>",
version,
styles = styling::Styles::styled()
.header(styling::AnsiColor::Green.on_default().bold())
.usage(styling::AnsiColor::Green.on_default().bold())
.literal(styling::AnsiColor::Cyan.on_default().bold())
.placeholder(styling::AnsiColor::Cyan.on_default())
)]
pub struct Config {
#[command(subcommand)]
subcommand: Option<SubCommand>,
#[arg(
default_value = "kubernix-run",
env = "KUBERNIX_ROOT",
global = true,
long = "root",
short = 'r',
value_name = "PATH"
)]
root: PathBuf,
#[arg(
default_value = "info",
env = "KUBERNIX_LOG_LEVEL",
long = "log-level",
short = 'l',
value_name = "LEVEL"
)]
log_level: LevelFilter,
#[arg(
default_value = "text",
env = "KUBERNIX_LOG_FORMAT",
long = "log-format",
short = 'f',
value_name = "FORMAT"
)]
log_format: LogFormat,
#[arg(
default_value = "10.10.0.0/16",
env = "KUBERNIX_CIDR",
long = "cidr",
short = 'c',
value_name = "CIDR"
)]
cidr: Ipv4Network,
#[arg(
env = "KUBERNIX_OVERLAY",
long = "overlay",
short = 'o',
value_name = "PATH"
)]
overlay: Option<PathBuf>,
#[arg(
env = "KUBERNIX_PACKAGES",
long = "packages",
num_args = 1..,
short = 'p',
value_name = "PACKAGE",
)]
packages: Vec<String>,
#[arg(
env = "KUBERNIX_SHELL",
long = "shell",
short = 's',
value_name = "SHELL"
)]
shell: Option<String>,
#[arg(
default_value = "1",
env = "KUBERNIX_NODES",
long = "nodes",
short = 'n',
value_name = "NODES"
)]
nodes: u8,
#[arg(
env = "KUBERNIX_CONTAINER_RUNTIME",
long = "container-runtime",
default_value = Podman::EXECUTABLE,
requires = "nodes",
short = 'u',
value_name = "RUNTIME",
)]
container_runtime: String,
#[arg(
conflicts_with = "shell",
env = "KUBERNIX_NO_SHELL",
long = "no-shell",
short = 'e'
)]
no_shell: bool,
#[arg(
env = "KUBERNIX_DOCKERFILE",
long = "dockerfile",
short = 'd',
value_name = "PATH"
)]
dockerfile: Option<PathBuf>,
#[arg(
default_value = "coredns",
env = "KUBERNIX_ADDONS",
long = "addons",
short = 'a',
num_args = 0..,
value_name = "ADDON",
)]
addons: Vec<String>,
}
impl Config {
pub fn subcommand(&self) -> &Option<SubCommand> {
&self.subcommand
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn log_level(&self) -> LevelFilter {
self.log_level
}
pub fn log_format(&self) -> LogFormat {
self.log_format
}
pub fn cidr(&self) -> Ipv4Network {
self.cidr
}
pub fn overlay(&self) -> Option<&Path> {
self.overlay.as_deref()
}
pub fn packages(&self) -> &[String] {
&self.packages
}
pub fn shell(&self) -> Option<&str> {
self.shell.as_deref()
}
pub fn nodes(&self) -> u8 {
self.nodes
}
pub fn container_runtime(&self) -> &str {
&self.container_runtime
}
pub fn no_shell(&self) -> bool {
self.no_shell
}
pub fn dockerfile(&self) -> Option<&Path> {
self.dockerfile.as_deref()
}
pub fn addons(&self) -> &[String] {
&self.addons
}
}
#[derive(Clone, ClapSubcommand, Deserialize, Serialize)]
pub enum SubCommand {
#[command(name = "shell")]
Shell,
}
impl Default for Config {
fn default() -> Self {
let mut config = Self::parse();
if config.shell.is_none() {
config.shell = System::shell().ok();
}
config
}
}
impl Config {
const FILENAME: &'static str = "kubernix.toml";
pub fn canonicalize_root(&mut self) -> Result<()> {
self.create_root_dir()?;
self.root = canonicalize(self.root()).with_context(|| {
format!(
"Unable to canonicalize config root directory '{}'",
self.root().display()
)
})?;
Ok(())
}
pub fn to_file(&self) -> Result<()> {
self.create_root_dir()?;
fs::write(self.root().join(Self::FILENAME), toml::to_string(&self)?)
.context("Unable to write configuration to file")?;
Ok(())
}
pub fn try_load_file(&mut self) -> Result<()> {
let file = self.root().join(Self::FILENAME);
if file.exists() {
*self = toml::from_str(&read_to_string(&file).with_context(|| {
format!(
"Unable to read expected configuration file '{}'",
file.display(),
)
})?)
.with_context(|| format!("Unable to load config file '{}'", file.display()))?;
} else {
self.to_file()?;
}
Ok(())
}
pub fn shell_ok(&self) -> Result<String> {
let shell = self.shell.as_ref().context("No shell set")?;
Ok(shell.into())
}
pub fn multi_node(&self) -> bool {
self.nodes() > 1
}
fn create_root_dir(&self) -> Result<()> {
create_dir_all(self.root()).with_context(|| {
format!(
"Unable to create root directory '{}'",
self.root().display()
)
})
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
pub fn test_config() -> Result<Config> {
let mut c = Config::parse_from(&[] as &[&str]);
if c.shell.is_none() {
c.shell = System::shell().ok();
}
c.root = tempdir()?.keep();
c.canonicalize_root()?;
Ok(c)
}
pub fn test_config_wrong_root() -> Result<Config> {
let mut c = test_config()?;
c.root = Path::new("/").join("proc");
Ok(c)
}
pub fn test_config_wrong_cidr() -> Result<Config> {
let mut c = test_config()?;
c.cidr = "10.0.0.1/31".parse()?;
Ok(c)
}
#[test]
fn canonicalize_root_success() -> Result<()> {
let mut c = Config::default();
c.root = tempdir()?.keep();
c.canonicalize_root()
}
#[test]
fn canonicalize_root_failure() {
let mut c = Config::default();
c.root = Path::new("/").join("proc").join("invalid");
assert!(c.canonicalize_root().is_err())
}
#[test]
fn to_file_success() -> Result<()> {
let mut c = Config::default();
c.root = tempdir()?.keep();
c.to_file()
}
#[test]
fn to_file_failure() {
let mut c = Config::default();
c.root = Path::new("/").join("proc").join("invalid");
assert!(c.to_file().is_err())
}
#[test]
fn try_load_file_success() -> Result<()> {
let mut c = Config::default();
c.root = tempdir()?.keep();
fs::write(
c.root.join(Config::FILENAME),
r#"
addons = ["coredns"]
cidr = "1.1.1.1/16"
container-runtime = "podman"
log-format = "text"
log-level = "DEBUG"
no-shell = false
nodes = 1
packages = []
root = "root"
"#,
)?;
c.try_load_file()?;
assert_eq!(c.root(), Path::new("root"));
assert_eq!(c.log_level(), LevelFilter::Debug);
assert_eq!(c.log_format(), LogFormat::Text);
assert_eq!(&c.cidr().to_string(), "1.1.1.1/16");
assert!(c.dockerfile().is_none());
Ok(())
}
#[test]
fn try_load_file_with_dockerfile() -> Result<()> {
let mut c = Config::default();
c.root = tempdir()?.keep();
fs::write(
c.root.join(Config::FILENAME),
r#"
addons = ["coredns"]
cidr = "1.1.1.1/16"
container-runtime = "podman"
dockerfile = "/tmp/MyDockerfile"
log-format = "json"
log-level = "DEBUG"
no-shell = false
nodes = 1
packages = []
root = "root"
"#,
)?;
c.try_load_file()?;
assert_eq!(c.log_format(), LogFormat::Json);
assert_eq!(c.dockerfile(), Some(Path::new("/tmp/MyDockerfile")));
Ok(())
}
#[test]
fn try_load_file_failure() -> Result<()> {
let mut c = Config::default();
c.root = tempdir()?.keep();
fs::write(c.root.join(Config::FILENAME), "invalid")?;
assert!(c.try_load_file().is_err());
Ok(())
}
}