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, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum CriRuntime {
#[default]
Crio,
Containerd,
}
impl fmt::Display for CriRuntime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CriRuntime::Crio => write!(f, "crio"),
CriRuntime::Containerd => write!(f, "containerd"),
}
}
}
#[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>,
#[serde(default)]
#[arg(
default_value = "crio",
env = "KUBERNIX_CRI_RUNTIME",
long = "cri-runtime",
value_name = "CRI_RUNTIME"
)]
cri_runtime: CriRuntime,
#[serde(skip)]
#[arg(skip)]
rootless: bool,
#[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 cri_runtime(&self) -> CriRuntime {
self.cri_runtime
}
pub fn addons(&self) -> &[String] {
&self.addons
}
pub fn is_rootless(&self) -> bool {
self.rootless
}
pub fn set_rootless(&mut self, val: bool) {
self.rootless = val;
}
}
#[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_rootless() -> Result<Config> {
let mut c = test_config()?;
c.set_rootless(true);
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 {
root: tempdir()?.keep(),
..Config::default()
};
c.canonicalize_root()
}
#[test]
fn canonicalize_root_failure() {
let mut c = Config {
root: Path::new("/").join("proc").join("invalid"),
..Config::default()
};
assert!(c.canonicalize_root().is_err())
}
#[test]
fn to_file_success() -> Result<()> {
let c = Config {
root: tempdir()?.keep(),
..Config::default()
};
c.to_file()
}
#[test]
fn to_file_failure() {
let c = Config {
root: Path::new("/").join("proc").join("invalid"),
..Config::default()
};
assert!(c.to_file().is_err())
}
#[test]
fn try_load_file_success() -> Result<()> {
let mut c = Config {
root: tempdir()?.keep(),
..Config::default()
};
fs::write(
c.root.join(Config::FILENAME),
r#"
addons = ["coredns"]
cidr = "1.1.1.1/16"
container-runtime = "podman"
cri-runtime = "crio"
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_eq!(c.cri_runtime(), CriRuntime::Crio);
assert!(c.dockerfile().is_none());
Ok(())
}
#[test]
fn try_load_file_with_dockerfile() -> Result<()> {
let mut c = Config {
root: tempdir()?.keep(),
..Config::default()
};
fs::write(
c.root.join(Config::FILENAME),
r#"
addons = ["coredns"]
cidr = "1.1.1.1/16"
container-runtime = "podman"
cri-runtime = "containerd"
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.cri_runtime(), CriRuntime::Containerd);
assert_eq!(c.dockerfile(), Some(Path::new("/tmp/MyDockerfile")));
Ok(())
}
#[test]
fn try_load_file_without_cri_runtime() -> Result<()> {
let mut c = Config {
root: tempdir()?.keep(),
..Config::default()
};
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.cri_runtime(), CriRuntime::Crio);
Ok(())
}
#[test]
fn try_load_file_failure() -> Result<()> {
let mut c = Config {
root: tempdir()?.keep(),
..Config::default()
};
fs::write(c.root.join(Config::FILENAME), "invalid")?;
assert!(c.try_load_file().is_err());
Ok(())
}
#[test]
fn rootless_default_false() -> Result<()> {
let c = test_config()?;
assert!(!c.is_rootless());
Ok(())
}
#[test]
fn rootless_set_and_get() -> Result<()> {
let c = test_config_rootless()?;
assert!(c.is_rootless());
Ok(())
}
#[test]
fn rootless_not_serialized() -> Result<()> {
let mut c = test_config()?;
c.set_rootless(true);
let toml = toml::to_string(&c).unwrap();
assert!(!toml.contains("rootless"));
Ok(())
}
}