///| pm3's own configuration: the applications it should run.
///
/// This is the pm3 equivalent of PM2's `ecosystem.config.js` — a declarative
/// list of processes, so a project can be brought up with one command instead
/// of a shell script full of `pm3 start` calls:
///
/// ```toml
/// # pm3.toml
/// [defaults] # optional, applied to every app below
/// max_restarts = 16
/// max_memory_restart = "200M"
///
/// [[app]]
/// name = "worker"
/// script = "./worker.py"
/// interpreter = "python3"
/// args = ["--verbose"]
/// env = { PORT = "8080" }
/// ```
///
/// The same document in JSON:
///
/// ```json
/// {
/// "defaults": { "max_restarts": 16 },
/// "app": [
/// { "name": "worker", "script": "./worker.py", "interpreter": "python3" }
/// ]
/// }
/// ```
///
/// Paths inside a config file are resolved relative to the file itself, so a
/// checked-in `pm3.toml` works regardless of the directory pm3 is invoked
/// from. `cwd` defaults to the config file's directory for the same reason.
///|
/// Field names an `[[app]]` entry understands. Anything else is rejected, so a
/// typo is reported rather than silently ignored.
///
/// `exec_interpreter`, `interpreterArgs` and `node_args` are accepted because
/// PM2's ecosystem documents them — a config carried over from PM2 should load
/// as-is.
pub fn app_field_names() -> Array[String] {
[
"name", "script", "args", "namespace", "interpreter", "exec_interpreter",
"interpreter_args", "interpreterArgs", "node_args", "cwd", "env", "autorestart",
"max_restarts", "restart_delay", "min_uptime", "max_memory_restart", "kill_timeout",
"kill_signal", "stop_exit_codes", "merge_logs", "out_log", "err_log",
"log_date_format",
]
}
///|
fn read_string_array_field(
members : Map[String, Json],
key : String,
) -> Result[Array[String]?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::Array(items)) => {
let out : Array[String] = []
for item in items {
match item {
Json::String(text) => out.push(text)
_ => return Err("every entry of '" + key + "' must be a string")
}
}
Ok(Some(out))
}
Some(_) => Err("'" + key + "' must be an array of strings")
}
}
///|
fn read_int_array_field(
members : Map[String, Json],
key : String,
) -> Result[Array[Int]?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::Array(items)) => {
let out : Array[Int] = []
for item in items {
match item {
Json::Number(n, ..) => out.push(n.to_int())
Json::String(text) => {
let parsed : Int? = Some(@string.parse_int(text)) catch {
_ => None
}
match parsed {
Some(value) => out.push(value)
None =>
return Err("every entry of '" + key + "' must be an integer")
}
}
_ => return Err("every entry of '" + key + "' must be an integer")
}
}
Ok(Some(out))
}
Some(_) => Err("'" + key + "' must be an array of integers")
}
}
///|
fn read_string_map_field(
members : Map[String, Json],
key : String,
) -> Result[Map[String, String]?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::Object(entries)) => {
let out : Map[String, String] = Map([])
for name, value in entries {
match value {
Json::String(text) => out.set(name, text)
Json::Number(n, ..) => out.set(name, n.to_string())
Json::True => out.set(name, "true")
Json::False => out.set(name, "false")
_ => return Err("'" + key + "." + name + "' must be a scalar")
}
}
Ok(Some(out))
}
Some(_) => Err("'" + key + "' must be a table or object of scalars")
}
}
///|
/// Read the first alias that is present, so both PM2's and pm3's spelling of a
/// field work. Values are checked with `read_text_field`, so a non-string is
/// still an error.
fn read_aliased_text_field(
members : Map[String, Json],
keys : Array[String],
) -> Result[String?, String] {
for key in keys {
match read_text_field(members, key) {
Ok(None) => ()
Ok(Some(value)) => return Ok(Some(value))
Err(message) => return Err(message)
}
}
Ok(None)
}
///|
/// Read `interpreter_args` under any of the names PM2 accepts — the snake_case
/// form used by ecosystem files, the camelCase form used by `ecosystem.config.js`
/// authors, and PM2's own `node_args`.
///
/// A single string is split on whitespace the way PM2 splits it, so both
/// `interpreter_args = "--harmony --max-old-space-size=512"` and
/// `interpreter_args = ["--harmony"]` work.
fn read_interpreter_args_field(
members : Map[String, Json],
) -> Result[Array[String]?, String] {
for key in ["interpreter_args", "interpreterArgs", "node_args"] {
match members.get(key) {
None => ()
Some(Json::String(text)) => return Ok(Some(split_shell_words(text)))
_ => return read_string_array_field(members, key)
}
}
Ok(None)
}
///|
/// Copy an application, keeping only the launch specification. Runtime fields
/// reset to their fresh values, which is what a config file describes.
fn clone_app(app : App) -> App {
let copy = App::new(app.name, app.script, app.args.copy())
copy.app_namespace = app.app_namespace
copy.interpreter = app.interpreter
copy.interpreter_args = app.interpreter_args.copy()
copy.cwd = app.cwd
copy.env = Map([])
for key, value in app.env {
copy.env.set(key, value)
}
copy.out_log = app.out_log
copy.err_log = app.err_log
copy.merge_logs = app.merge_logs
copy.log_date_format = app.log_date_format
copy.autorestart = app.autorestart
copy.max_restarts = app.max_restarts
copy.restart_delay = app.restart_delay
copy.min_uptime = app.min_uptime
copy.max_memory_restart = app.max_memory_restart
copy.kill_timeout = app.kill_timeout
copy.kill_signal = app.kill_signal
copy.stop_exit_codes = app.stop_exit_codes.copy()
copy
}
///|
/// Resolve the `script` of an app.
///
/// A bare name that is not a file next to the config — `sh`, `python3`, `go` —
/// is left alone so it is looked up on `PATH`, exactly as `pm3 start sh`
/// would. Anything with a slash in it, or a name that does exist next to the
/// config, is a path and is resolved.
fn resolve_script_path(base_dir : String, value : String) -> String {
if value.is_empty() || base_dir.is_empty() || value.has_prefix("/") {
return value
}
if value.contains("/") || @fs.path_exists(path_join(base_dir, value)) {
return resolve_config_path(base_dir, value)
}
value
}
///|
/// Resolve a path from a config file against the file's own directory.
fn resolve_config_path(base_dir : String, value : String) -> String {
if value.is_empty() || base_dir.is_empty() || value.has_prefix("/") {
return value
}
let relative = if value.has_prefix("./") {
value[2:].to_owned()
} else {
value
}
path_join(base_dir, relative)
}
///|
/// Read one app table, layered over `base` (the defaults).
fn app_from_table(
members : Map[String, Json],
base : App,
base_dir : String,
) -> App raise ConfigError {
let known = app_field_names()
for key, _ in members {
if !known.contains(key) {
raise ConfigError(
"unknown app setting '" + key + "'; expected one of " + known.join(", "),
)
}
}
let app = clone_app(base)
let mut script_set = false
let mut name_set = false
let mut cwd_set = false
let mut out_set = false
let mut err_set = false
apply_field(read_text_field(members, "name"), v => {
app.name = v
name_set = true
})
apply_field(read_text_field(members, "script"), v => {
app.script = resolve_script_path(base_dir, v)
script_set = true
})
apply_field(read_string_array_field(members, "args"), v => app.args = v)
apply_field(read_text_field(members, "namespace"), v => {
app.app_namespace = v
})
apply_field(
read_aliased_text_field(members, ["interpreter", "exec_interpreter"]),
v => app.interpreter = v,
)
apply_field(read_interpreter_args_field(members), v => {
app.interpreter_args = v
})
apply_field(read_text_field(members, "cwd"), v => {
app.cwd = resolve_config_path(base_dir, v)
cwd_set = true
})
apply_field(read_string_map_field(members, "env"), v => {
for key, value in v {
app.env.set(key, value)
}
})
apply_field(read_bool_field(members, "autorestart"), v => app.autorestart = v)
apply_field(read_int_field(members, "max_restarts"), v => app.max_restarts = v)
apply_field(read_int_field(members, "restart_delay"), v => {
app.restart_delay = v
})
apply_field(read_int_field(members, "min_uptime"), v => app.min_uptime = v)
apply_field(read_size_field(members, "max_memory_restart"), v => {
app.max_memory_restart = v
})
apply_field(read_int_field(members, "kill_timeout"), v => app.kill_timeout = v)
apply_field(read_text_field(members, "kill_signal"), v => app.kill_signal = v)
apply_field(read_int_array_field(members, "stop_exit_codes"), v => {
app.stop_exit_codes = v
})
apply_field(read_bool_field(members, "merge_logs"), v => app.merge_logs = v)
apply_field(read_text_field(members, "out_log"), v => {
app.out_log = resolve_config_path(base_dir, v)
out_set = true
})
apply_field(read_text_field(members, "err_log"), v => {
app.err_log = resolve_config_path(base_dir, v)
err_set = true
})
apply_field(read_text_field(members, "log_date_format"), v => {
app.log_date_format = v
})
if !script_set || app.script.is_empty() {
raise ConfigError("every app needs a 'script'")
}
if !name_set || app.name.is_empty() {
app.name = derive_config_app_name(app.script)
}
// A name that only became known here still decides the default log paths.
if !out_set {
app.out_log = default_log_path(app.name, "out")
}
if !err_set {
app.err_log = default_log_path(app.name, "err")
}
if !cwd_set {
app.cwd = base_dir
}
if app.kill_signal.is_empty() {
app.kill_signal = default_kill_signal()
}
if app.max_restarts < 0 {
raise ConfigError("'max_restarts' must not be negative")
}
if app.restart_delay < 0 {
raise ConfigError("'restart_delay' must not be negative")
}
if app.kill_timeout < 0 {
raise ConfigError("'kill_timeout' must not be negative")
}
app
}
///|
/// Derive an app name from a script path: the basename without its extension.
pub fn derive_config_app_name(script : String) -> String {
let base = path_basename(script)
match base.rev_find(".") {
Some(index) if index > 0 =>
base.view(start_offset=0, end_offset=index).to_owned()
_ => base
}
}
///|
/// Read the `[defaults]` table that every app in the document inherits.
fn read_app_defaults(members : Map[String, Json]) -> App raise ConfigError {
let base = App::new("", "", [])
let table = match members.get("defaults") {
None => return base
Some(Json::Object(inner)) => inner
Some(_) => raise ConfigError("'defaults' must be a table or object")
}
// Reuse the per-app reader, minus the checks that only make sense for a
// concrete app.
let app = clone_app(base)
let known = app_field_names()
for key, _ in table {
if !known.contains(key) {
raise ConfigError(
"unknown default '" + key + "'; expected one of " + known.join(", "),
)
}
}
apply_field(read_string_array_field(table, "args"), v => app.args = v)
apply_field(read_text_field(table, "namespace"), v => {
app.app_namespace = v
})
apply_field(
read_aliased_text_field(table, ["interpreter", "exec_interpreter"]),
v => app.interpreter = v,
)
apply_field(read_interpreter_args_field(table), v => app.interpreter_args = v)
apply_field(read_string_map_field(table, "env"), v => {
for key, value in v {
app.env.set(key, value)
}
})
apply_field(read_bool_field(table, "autorestart"), v => app.autorestart = v)
apply_field(read_int_field(table, "max_restarts"), v => app.max_restarts = v)
apply_field(read_int_field(table, "restart_delay"), v => app.restart_delay = v)
apply_field(read_int_field(table, "min_uptime"), v => app.min_uptime = v)
apply_field(read_size_field(table, "max_memory_restart"), v => {
app.max_memory_restart = v
})
apply_field(read_int_field(table, "kill_timeout"), v => app.kill_timeout = v)
apply_field(read_text_field(table, "kill_signal"), v => app.kill_signal = v)
apply_field(read_int_array_field(table, "stop_exit_codes"), v => {
app.stop_exit_codes = v
})
apply_field(read_bool_field(table, "merge_logs"), v => app.merge_logs = v)
apply_field(read_text_field(table, "log_date_format"), v => {
app.log_date_format = v
})
if app.kill_signal.is_empty() {
app.kill_signal = default_kill_signal()
}
app
}
///|
/// Whether a document declares any applications.
pub fn has_apps(json : Json) -> Bool {
match json {
Object(members) =>
match members.get("app") {
Some(Json::Array(items)) => items.length() > 0
_ =>
match members.get("apps") {
Some(Json::Array(items)) => items.length() > 0
_ => false
}
}
_ => false
}
}
///|
/// Build the application list declared in a parsed config document.
///
/// `base_dir` is the directory of the config file, used to resolve the paths
/// it contains.
pub fn apps_from_json(
json : Json,
base_dir : String,
) -> Result[Array[App], String] {
Ok(read_apps(json, base_dir)) catch {
ConfigError(msg) => Err(msg)
}
}
///|
fn read_apps(json : Json, base_dir : String) -> Array[App] raise ConfigError {
let members = match json {
Object(members) => members
_ => raise ConfigError("the config file must be a table or object")
}
let defaults = read_app_defaults(members)
let entries = match members.get("app") {
Some(Json::Array(items)) => items
Some(_) => raise ConfigError("'app' must be an array of tables: [[app]]")
None =>
match members.get("apps") {
Some(Json::Array(items)) => items
Some(_) =>
raise ConfigError("'apps' must be an array of tables: [[app]]")
None =>
raise ConfigError(
"no applications declared; add one with [[app]] and a 'script'",
)
}
}
let apps : Array[App] = []
for entry in entries {
let table = match entry {
Object(inner) => inner
_ => raise ConfigError("every [[app]] entry must be a table")
}
apps.push(app_from_table(table, defaults, base_dir))
}
if apps.is_empty() {
raise ConfigError("no applications declared")
}
apps
}
///|
/// The absolute directory a config file lives in.
///
/// Relative paths inside the file are resolved against it, so a checked-in
/// `pm3.toml` behaves the same no matter where pm3 was invoked from — and
/// unlike the daemon's working directory, which is whatever it was started
/// with.
fn absolute_config_dir(path : String) -> String {
let full = if path.has_prefix("/") {
path
} else {
match @env.current_dir() {
Some(dir) => path_join(dir, path)
None => path
}
}
path_dirname(full)
}
///|
/// Whether a document carries a `[logrotate]` table.
fn declares_logrotate(json : Json) -> Bool {
match json {
Object(members) => members.contains("logrotate")
_ => false
}
}
///|
/// Whether a path is the global config file — the only one whose `[logrotate]`
/// table means anything.
fn is_global_config(path : String) -> Bool {
let full = if path.has_prefix("/") {
path
} else {
match @env.current_dir() {
Some(dir) => path_join(dir, path)
None => path
}
}
for candidate in pm3_config_candidates() {
if candidate == full {
return true
}
}
false
}
///|
/// Read and parse an application config file, returning the apps it declares.
pub fn load_apps_from_file(path : String) -> Result[Array[App], String] {
match read_config_file(path) {
Err(msg) => Err(msg)
Ok(json) => {
// The rotation policy is global: the daemon is one supervisor for the
// whole machine, so a project-local `[logrotate]` table would be read by
// nobody. Saying so beats letting it sit there looking effective.
if !is_global_config(path) && declares_logrotate(json) {
return Err(
"log rotation is configured globally, in " +
pm3_config_toml_path() +
"; remove the [logrotate] table from this file",
)
}
apps_from_json(json, absolute_config_dir(path))
}
}
}
///|
/// Whether a path names a config file pm3 should read rather than a program
/// to run. Only TOML and JSON are ever config files.
pub fn is_app_config_file(path : String) -> Bool {
(path.has_suffix(".toml") || path.has_suffix(".json")) &&
@fs.path_exists(path)
}
///|
/// Configuration files pm3 looks for when `pm3 start` is given no script.
///
/// A project-local `pm3.toml` comes first, so a checked-in file wins over the
/// user's global defaults; `PM3_CONFIG` overrides both. This list is about
/// *applications* only — the rotation policy is read from the global file
/// alone, whatever else exists.
pub fn app_config_candidates() -> Array[String] {
let out : Array[String] = []
match @env.get_env_var("PM3_CONFIG") {
Some(path) if path.length() > 0 => out.push(path)
_ => ()
}
out.push("pm3.toml")
out.push("pm3.json")
out.push(pm3_config_toml_path())
out.push(pm3_config_json_path())
out
}
///|
/// The first application config file that exists, or `None`.
pub fn find_app_config() -> String? {
for path in app_config_candidates() {
if @fs.path_exists(path) {
return Some(path)
}
}
None
}