use std::collections::HashMap;
use shep_core::config::{AppConfig, normalize};
use shep_core::values::{MemSize, UpDuration};
use super::dump::DumpRow;
use super::env;
#[derive(Debug)]
pub(crate) struct Imported {
pub apps: Vec<AppConfig>,
pub notes: Vec<ImportNote>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ImportNote {
ClusterMode {
app: String,
instances: u32,
},
InheritedEnv {
app: String,
key: String,
},
UnrepresentableEnv {
app: String,
key: String,
},
InstanceVar {
app: String,
var: String,
},
}
#[derive(Debug)]
pub(crate) enum ConvertError {
Rejected {
name: String,
reason: String,
},
}
impl core::fmt::Display for ConvertError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Rejected { name, reason } => {
write!(f, "`{name}` does not normalize: {reason}")
}
}
}
}
impl core::error::Error for ConvertError {}
pub(crate) fn convert(rows: Vec<DumpRow>) -> Result<Imported, ConvertError> {
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, Vec<DumpRow>> = HashMap::new();
for row in rows {
groups
.entry(row.name.clone())
.or_insert_with(|| {
order.push(row.name.clone());
Vec::new()
})
.push(row);
}
let mut apps = Vec::with_capacity(order.len());
let mut notes = Vec::new();
for name in order {
let group = groups
.remove(&name)
.expect("`order` names only keys just inserted into `groups`");
let (app, app_notes) = convert_group(&name, &group);
notes.extend(app_notes);
let resolved = normalize(app).map_err(|err| ConvertError::Rejected {
name: name.clone(),
reason: err.to_string(),
})?;
apps.push(resolved.into_config());
}
Ok(Imported { apps, notes })
}
fn convert_group(name: &str, rows: &[DumpRow]) -> (AppConfig, Vec<ImportNote>) {
let mut notes = Vec::new();
let instances = rows.len() as u32;
let first = &rows[0];
let mut app = AppConfig::minimal(name, &first.pm_exec_path);
app.args = first.args.clone();
app.cwd = first.pm_cwd.clone();
app.interpreter = first
.exec_interpreter
.clone()
.filter(|interpreter| interpreter != "none");
if let Some(autorestart) = first.autorestart {
app.autorestart = autorestart;
}
if let Some(restart_delay) = first.restart_delay {
app.restart_delay = Some(UpDuration::from_millis(restart_delay));
}
if let Some(merge_logs) = first.merge_logs {
app.merge_logs = merge_logs;
}
if let Some(max_memory_restart) = first.max_memory_restart {
app.max_memory = Some(MemSize::from_bytes(max_memory_restart));
}
app.instances = instances;
if first.exec_mode.as_deref() == Some("cluster_mode") {
notes.push(ImportNote::ClusterMode {
app: name.to_string(),
instances,
});
}
let app_env = env::split(first);
app.env = app_env.env;
if let Some(var) = app_env.instance_var {
app.increment_var = Some(var.clone());
notes.push(ImportNote::InstanceVar {
app: name.to_string(),
var,
});
}
for key in app_env.inherited {
notes.push(ImportNote::InheritedEnv {
app: name.to_string(),
key,
});
}
for key in &first.unrepresentable {
notes.push(ImportNote::UnrepresentableEnv {
app: name.to_string(),
key: key.clone(),
});
}
(app, notes)
}
#[cfg(test)]
mod tests {
use shep_core::values::{MemSize, UpDuration};
use super::*;
use crate::commands::import::dump;
fn imported() -> Imported {
convert(dump::parse(include_str!("testdata/dump.pm2.json")).unwrap()).unwrap()
}
#[test]
fn four_instance_rows_collapse_into_three_apps() {
let imported = imported();
let names: Vec<&str> = imported.apps.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["api", "worker", "migrate"]);
assert_eq!(imported.apps[0].instances, 2, "api ran two instances");
assert_eq!(imported.apps[1].instances, 1);
}
#[test]
fn same_named_rows_collapse_even_when_not_adjacent() {
let mut rows = dump::parse(include_str!("testdata/dump.pm2.json")).unwrap();
let second_api = rows.remove(1);
rows.push(second_api);
let imported = convert(rows).unwrap();
let names: Vec<&str> = imported.apps.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["api", "worker", "migrate"]);
assert_eq!(
imported.apps[0].instances, 2,
"the two `api` rows must still collapse once they are no longer adjacent"
);
}
#[test]
fn every_mapped_field_lands_where_the_table_says() {
let imported = imported();
let api = &imported.apps[0];
assert_eq!(api.script, "/srv/api/dist/server.js");
assert_eq!(api.args, ["--port", "8080"]);
assert_eq!(api.cwd.as_deref(), Some("/srv/api"));
assert_eq!(api.interpreter.as_deref(), Some("node"));
assert_eq!(api.max_memory, Some(MemSize::from_bytes(536_870_912)));
assert!(api.autorestart);
let worker = &imported.apps[1];
assert_eq!(worker.interpreter.as_deref(), Some("bun"));
assert!(!worker.autorestart);
assert_eq!(worker.restart_delay, Some(UpDuration::from_millis(5000)));
assert!(worker.merge_logs);
let migrate = &imported.apps[2];
assert_eq!(migrate.interpreter, None);
assert_eq!(migrate.script, "/srv/migrate/bin/migrate");
}
#[test]
fn cluster_mode_says_so_without_setting_a_field_nothing_reads() {
let imported = imported();
assert!(imported.notes.contains(&ImportNote::ClusterMode {
app: "api".to_string(),
instances: 2,
}));
for app in &imported.apps {
assert!(
!app.reuse_port,
"`{}`: the importer must not emit a field normalize refuses",
app.name
);
}
}
#[test]
fn the_pm2_instance_variable_becomes_increment_var_and_never_a_value() {
let imported = imported();
let api = &imported.apps[0];
assert_eq!(api.increment_var.as_deref(), Some("NODE_APP_INSTANCE"));
assert!(!api.env.contains_key("NODE_APP_INSTANCE"));
assert!(imported.notes.contains(&ImportNote::InstanceVar {
app: "api".to_string(),
var: "NODE_APP_INSTANCE".to_string(),
}));
}
#[test]
fn every_mapped_app_normalizes() {
for app in imported().apps {
let name = app.name.clone();
shep_core::config::normalize(app).unwrap_or_else(|err| panic!("{name}: {err}"));
}
}
}