use std::process;
use clap::Parser;
use tracing::{Event, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::EnvFilter;
use crate::cli::{Cli, Commands, ConfigFormat};
pub(crate) fn is_mutating(command: &Commands) -> bool {
matches!(
command,
Commands::Up { .. }
| Commands::Down { .. }
| Commands::Start { .. }
| Commands::Stop { .. }
| Commands::Build { .. }
| Commands::Rm { .. }
| Commands::Kill { .. }
| Commands::Pause { .. }
| Commands::Unpause { .. }
| Commands::Run { .. }
| Commands::Restart { .. }
| Commands::Scale { .. }
| Commands::Create { .. }
)
}
const REPO_URL: &str = "https://github.com/Glyndor/podup";
struct PodupFormat;
impl<S, N> FormatEvent<S, N> for PodupFormat
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> std::fmt::Result {
write!(writer, "podup: {}: ", level_word(*event.metadata().level()))?;
ctx.field_format().format_fields(writer.by_ref(), event)?;
writeln!(writer)
}
}
fn level_word(level: tracing::Level) -> &'static str {
match level {
tracing::Level::ERROR => "error",
tracing::Level::WARN => "warning",
tracing::Level::INFO => "info",
tracing::Level::DEBUG => "debug",
tracing::Level::TRACE => "trace",
}
}
pub(crate) fn internal_error_notice() -> String {
format!(
"podup: this looks like a bug; re-run with RUST_LOG=debug and report it at {REPO_URL}/issues\n\
podup: redact secrets (passwords, tokens, resolved env values) from any logs before sharing"
)
}
pub(crate) fn init_tracing(default_level: &str) {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)),
)
.with_writer(std::io::stderr)
.event_format(PodupFormat)
.init();
}
pub(crate) fn render_config(
file: &podup::compose::types::ComposeFile,
format: &ConfigFormat,
services: bool,
quiet: bool,
) -> podup::Result<()> {
for (name, svc) in &file.services {
if svc.image.is_none() && svc.build.is_none() {
return Err(podup::ComposeError::NoImageOrBuild(name.clone()));
}
}
if quiet {
return Ok(());
}
if services {
for name in file.services.keys() {
println!("{name}");
}
return Ok(());
}
let mut redacted = file.clone();
redacted.redact_inline_content();
let rendered = match format {
ConfigFormat::Json => {
let mut v = serde_json::to_value(&redacted).map_err(|e| {
podup::ComposeError::Unsupported(format!("failed to render config as JSON: {e}"))
})?;
prune_json_nulls(&mut v);
serde_json::to_string_pretty(&v).map_err(|e| {
podup::ComposeError::Unsupported(format!("failed to render config as JSON: {e}"))
})?
}
ConfigFormat::Yaml => {
let mut v: serde_yaml::Value =
serde_yaml::to_value(&redacted).map_err(podup::ComposeError::Parse)?;
prune_yaml_nulls(&mut v);
serde_yaml::to_string(&v).map_err(podup::ComposeError::Parse)?
}
};
println!("{rendered}");
Ok(())
}
fn prune_json_nulls(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(map) => {
for val in map.values_mut() {
prune_json_nulls(val);
}
map.retain(|_, val| !is_empty_json(val));
}
serde_json::Value::Array(arr) => {
for val in arr.iter_mut() {
prune_json_nulls(val);
}
}
_ => {}
}
}
fn is_empty_json(v: &serde_json::Value) -> bool {
match v {
serde_json::Value::Null => true,
serde_json::Value::Object(m) => m.is_empty(),
_ => false,
}
}
fn prune_yaml_nulls(v: &mut serde_yaml::Value) {
match v {
serde_yaml::Value::Mapping(map) => {
for (_, val) in map.iter_mut() {
prune_yaml_nulls(val);
}
let drop: Vec<serde_yaml::Value> = map
.iter()
.filter(|(_, val)| is_empty_yaml(val))
.map(|(k, _)| k.clone())
.collect();
for k in drop {
map.remove(&k);
}
}
serde_yaml::Value::Sequence(seq) => {
for val in seq.iter_mut() {
prune_yaml_nulls(val);
}
}
_ => {}
}
}
fn is_empty_yaml(v: &serde_yaml::Value) -> bool {
match v {
serde_yaml::Value::Null => true,
serde_yaml::Value::Mapping(m) => m.is_empty(),
_ => false,
}
}
pub(crate) fn run_overrides_for(command: &Commands) -> podup::RunOverrides {
match command {
Commands::Run {
user,
workdir,
entrypoint,
volume,
publish,
interactive,
no_deps,
..
} => podup::RunOverrides {
user: user.clone(),
workdir: workdir.clone(),
entrypoint: entrypoint.clone(),
volumes: volume.clone(),
publish: publish.clone(),
interactive: *interactive,
no_deps: *no_deps,
},
_ => podup::RunOverrides::default(),
}
}
pub(crate) fn parse_cli() -> Cli {
match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => match e.kind() {
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| clap::error::ErrorKind::DisplayVersion => {
print!("\n{e}\n");
process::exit(0);
}
_ => e.exit(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prune_json_drops_nulls_and_empty_then_collapses() {
let mut v = serde_json::json!({
"image": "nginx",
"environment": null,
"command": [],
"labels": {},
"deploy": { "replicas": null }
});
prune_json_nulls(&mut v);
assert_eq!(v, serde_json::json!({ "image": "nginx", "command": [] }));
}
#[test]
fn prune_yaml_drops_nulls_and_empty() {
let mut v: serde_yaml::Value =
serde_yaml::from_str("image: nginx\ndns: null\nnetworks: {}\n").unwrap();
prune_yaml_nulls(&mut v);
let out = serde_yaml::to_string(&v).unwrap();
assert!(out.contains("image: nginx"));
assert!(!out.contains("dns"));
assert!(!out.contains("networks"));
}
#[test]
fn level_words_match_user_facing_terms() {
assert_eq!(level_word(tracing::Level::WARN), "warning");
assert_eq!(level_word(tracing::Level::ERROR), "error");
assert_eq!(level_word(tracing::Level::INFO), "info");
assert_eq!(level_word(tracing::Level::DEBUG), "debug");
assert_eq!(level_word(tracing::Level::TRACE), "trace");
}
fn sample_file() -> podup::compose::types::ComposeFile {
podup::parse_str("services:\n web:\n image: nginx\n db:\n image: postgres\n")
.unwrap()
}
#[test]
fn render_config_quiet_is_validate_only() {
render_config(&sample_file(), &ConfigFormat::Yaml, false, true).unwrap();
}
#[test]
fn render_config_services_lists_names() {
render_config(&sample_file(), &ConfigFormat::Yaml, true, false).unwrap();
}
#[test]
fn render_config_yaml_and_json_render_ok() {
render_config(&sample_file(), &ConfigFormat::Yaml, false, false).unwrap();
render_config(&sample_file(), &ConfigFormat::Json, false, false).unwrap();
}
#[test]
fn internal_error_notice_reports_and_warns_on_secrets() {
let notice = internal_error_notice();
assert!(notice.contains(REPO_URL), "points at the issue tracker");
assert!(notice.contains("/issues"));
assert!(
notice.contains("redact"),
"reminds the user to scrub secrets"
);
assert!(
notice.contains("RUST_LOG=debug"),
"tells the user what to capture"
);
}
}