use crate::config::Config;
use crate::{Error, Result};
pub const APP_VERSION_ENV: &str = "DBOS__APPVERSION";
pub const CLOUD_ENV: &str = "DBOS__CLOUD";
pub const APP_ID_ENV: &str = "DBOS__APPID";
pub const CLOUD_APP_NAME_ENV: &str = "DBOS_APP_NAME";
pub const EXECUTOR_ID_ENV: &str = "DBOS__VMID";
const DEFAULT_EXECUTOR_ID: &str = "local";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Environment {
pub cloud: bool,
pub app_id: String,
pub app_name: Option<String>,
pub app_version: Option<String>,
pub executor_id: Option<String>,
}
impl Environment {
pub(crate) fn read() -> Self {
let var = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty());
Self {
cloud: std::env::var(CLOUD_ENV).is_ok_and(|v| v.eq_ignore_ascii_case("true")),
app_id: var(APP_ID_ENV).unwrap_or_default(),
app_name: var(CLOUD_APP_NAME_ENV),
app_version: var(APP_VERSION_ENV),
executor_id: var(EXECUTOR_ID_ENV),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Identity {
pub app_name: String,
pub app_version: String,
pub executor_id: String,
pub app_id: String,
}
pub(crate) fn resolve(config: &Config, env: &Environment) -> Result<Identity> {
let (app_name, app_version, executor_id) = if env.cloud {
(
env.app_name.clone().unwrap_or_default(),
env.app_version.clone(),
env.executor_id.clone(),
)
} else {
(
config.app_name.clone(),
config
.app_version
.clone()
.or_else(|| env.app_version.clone()),
config
.executor_id
.clone()
.or_else(|| env.executor_id.clone()),
)
};
if app_name.is_empty() {
return Err(Error::Config(if env.cloud {
format!("{CLOUD_APP_NAME_ENV} must be set when {CLOUD_ENV} is true")
} else {
"`app_name` cannot be empty".to_owned()
}));
}
validate_app_name(&app_name)?;
let Some(app_version) = app_version else {
return Err(Error::Config(format!(
"no application version: set `app_version`, or the {APP_VERSION_ENV} environment \
variable. Nothing computes one — a Rust build is not reproducible, so a computed \
version would change under a rebuild that changed no code and leave the previous \
run's workflows unrecoverable"
)));
};
Ok(Identity {
app_name,
app_version,
executor_id: executor_id.unwrap_or_else(|| DEFAULT_EXECUTOR_ID.to_owned()),
app_id: env.app_id.clone(),
})
}
pub(crate) fn validate_app_name(name: &str) -> Result<()> {
let bad = |why: &str| Err(Error::Config(format!("`app_name` {why}: {name:?}")));
match name.chars().count() {
0 => return bad("cannot be empty"),
1..=2 => return bad("must be at least 3 characters"),
257.. => return bad("must be at most 256 characters"),
_ => {}
}
if let Some(c) = name
.chars()
.find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))
{
return bad(&format!(
"may contain only lowercase letters, digits, dashes and underscores, but has {c:?}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> Config {
Config {
app_version: Some("config-v1".to_owned()),
executor_id: Some("config-executor".to_owned()),
..Config::new("config-app", "postgres://x")
}
}
fn deployed() -> Environment {
Environment {
cloud: false,
app_id: "app-id-7".to_owned(),
app_name: Some("env-app".to_owned()),
app_version: Some("env-v9".to_owned()),
executor_id: Some("env-executor".to_owned()),
}
}
#[test]
fn the_configuration_outranks_the_environment_off_dbos_cloud() {
let resolved = resolve(&config(), &deployed()).unwrap();
assert_eq!(resolved.app_name, "config-app");
assert_eq!(resolved.app_version, "config-v1");
assert_eq!(resolved.executor_id, "config-executor");
assert_eq!(resolved.app_id, "app-id-7");
}
#[test]
fn the_environment_fills_in_what_the_configuration_leaves_unset() {
let config = Config::new("config-app", "postgres://x");
let resolved = resolve(&config, &deployed()).unwrap();
assert_eq!(resolved.app_version, "env-v9");
assert_eq!(resolved.executor_id, "env-executor");
assert_eq!(resolved.app_name, "config-app");
}
#[test]
fn dbos_cloud_outranks_the_configuration() {
let env = Environment {
cloud: true,
..deployed()
};
let resolved = resolve(&config(), &env).unwrap();
assert_eq!(resolved.app_name, "env-app");
assert_eq!(resolved.app_version, "env-v9");
assert_eq!(resolved.executor_id, "env-executor");
}
#[test]
fn an_unnamed_process_is_local() {
let config = Config {
executor_id: None,
..config()
};
let env = Environment {
executor_id: None,
..Environment::default()
};
assert_eq!(resolve(&config, &env).unwrap().executor_id, "local");
}
#[test]
fn a_version_nobody_supplied_is_an_error_naming_both_ways_to_supply_one() {
let config = Config::new("config-app", "postgres://x");
let err = resolve(&config, &Environment::default()).unwrap_err();
let message = err.to_string();
assert!(message.contains("app_version"), "{message}");
assert!(message.contains(APP_VERSION_ENV), "{message}");
}
#[test]
fn dbos_cloud_without_an_application_name_says_which_variable_is_missing() {
let env = Environment {
cloud: true,
app_name: None,
..deployed()
};
let err = resolve(&config(), &env).unwrap_err();
assert!(err.to_string().contains(CLOUD_APP_NAME_ENV), "{err}");
}
#[test]
fn an_app_name_is_held_to_the_rule_every_implementation_shares() {
for ok in ["abc", "my-app", "my_app_2", &"a".repeat(256)] {
assert!(validate_app_name(ok).is_ok(), "{ok:?} should be accepted");
}
for bad in [
"",
"ab",
&"a".repeat(257),
"My-App",
"my app",
"my.app",
"café",
] {
assert!(
validate_app_name(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn the_resolved_name_is_the_one_that_is_checked() {
let env = Environment {
cloud: true,
app_name: Some("Not A Name".to_owned()),
..deployed()
};
let err = resolve(&config(), &env).unwrap_err();
assert!(err.to_string().contains("Not A Name"), "{err}");
}
}