use super::env_var::interpolate_path_template;
use super::secret_registry::SecretRegistry;
use super::toml::{DeploymentToml, ServerConfigToml};
use crate::config::toml::DeploymentResolved;
use crate::config::toml::DeploymentTomlValidated;
use anyhow::{Context as _, bail};
use config::{Config, ConfigBuilder, Environment, File, FileFormat, builder::AsyncState};
use directories::{BaseDirs, ProjectDirs};
use std::path::{Path, PathBuf};
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt as _;
use tracing::info;
pub(crate) const OBELISK_HELP_SERVER_TOML: &str = include_str!("../../server-help.toml");
pub(crate) const OBELISK_TRUSTED_SERVER_TOML: &str =
include_str!("../../server-trusted-template.toml");
pub(crate) const OBELISK_HELP_DEPLOYMENT_TOML: &str = include_str!("../../deployment-help.toml");
const HOME_DIR_PREFIX: &str = "~/";
pub(crate) const DATA_DIR_PREFIX: &str = "${DATA_DIR}/";
pub(crate) const CACHE_DIR_PREFIX: &str = "${CACHE_DIR}/";
#[derive(Clone)]
pub(crate) struct PathPrefixes {
pub(crate) server_config_dir: Option<PathBuf>,
pub(crate) project_dirs: Option<ProjectDirs>,
pub(crate) base_dirs: Option<BaseDirs>,
}
impl PathPrefixes {
pub(crate) async fn server_config_replace_path_prefix_mkdir(
&self,
dir: &str,
secret_registry: &SecretRegistry,
) -> Result<PathBuf, anyhow::Error> {
let path = PathBuf::from(self.interpolate_path(dir, secret_registry)?);
tokio::fs::create_dir_all(&path)
.await
.with_context(|| format!("cannot create directory {path:?}"))?;
Ok(path)
}
fn interpolate_path(
&self,
dir: &str,
secret_registry: &SecretRegistry,
) -> Result<String, anyhow::Error> {
let dir = if let Some(suffix) = dir.strip_prefix(HOME_DIR_PREFIX) {
let home = self
.base_dirs
.as_ref()
.context("cannot expand `~/`: home directory is unavailable")?
.home_dir();
home.join(suffix).to_string_lossy().into_owned()
} else {
dir.to_owned()
};
interpolate_path_template(&dir, &self.synthetic_dirs(), secret_registry)
}
fn synthetic_dirs(&self) -> Vec<(&'static str, Option<String>)> {
let to_string = |p: &Path| p.to_string_lossy().into_owned();
let project_dirs = self.project_dirs.as_ref();
vec![
("DATA_DIR", project_dirs.map(|p| to_string(p.data_dir()))),
("CACHE_DIR", project_dirs.map(|p| to_string(p.cache_dir()))),
(
"CONFIG_DIR",
project_dirs.map(|p| to_string(p.config_dir())),
),
(
"SERVER_CONFIG_DIR",
self.server_config_dir.as_deref().map(to_string),
),
("TEMP_DIR", Some(to_string(&std::env::temp_dir()))),
]
}
}
#[derive(Clone)]
pub(crate) struct ConfigHolder {
pub(crate) config_source: Option<PathBuf>,
pub(crate) path_prefixes: PathPrefixes,
}
impl ConfigHolder {
pub(crate) async fn generate_server_config(
dst: PathBuf,
trusted: bool,
overwrite: bool,
) -> Result<PathBuf, anyhow::Error> {
write_config_file(&dst, server_config_template(trusted), overwrite).await?;
Ok(dst)
}
pub(crate) async fn generate_default_deployment_config(
dst: PathBuf,
overwrite: bool,
) -> Result<PathBuf, anyhow::Error> {
write_config_file(&dst, OBELISK_HELP_DEPLOYMENT_TOML, overwrite).await?;
Ok(dst)
}
pub(crate) fn new(
project_dirs: Option<ProjectDirs>,
base_dirs: Option<BaseDirs>,
server_config: Option<PathBuf>,
) -> Result<Self, anyhow::Error> {
let server_config_dir = if let Some(path) = &server_config {
let exists = path.try_exists().unwrap_or_default();
if !exists {
bail!("cannot find server config file {path:?}");
}
let canonical_parent = canonicalize_parent(path)
.with_context(|| format!("cannot resolve parent of {path:?}"))?;
Some(canonical_parent)
} else {
None
};
if let Some(path) = &server_config {
info!("Using server configuration file {:?}", path);
}
Ok(Self {
config_source: server_config,
path_prefixes: PathPrefixes {
server_config_dir,
project_dirs,
base_dirs,
},
})
}
pub(crate) fn load_config(&self) -> Result<ServerConfigToml, anyhow::Error> {
let mut builder = Config::builder();
if let Some(path) = &self.config_source {
builder = builder.add_source(
File::from(path.as_path())
.required(true)
.format(FileFormat::Toml),
);
}
builder = builder.add_source(Environment::with_prefix("obelisk").separator("__"));
let settings = builder.build()?;
let mut config: ServerConfigToml = settings.try_deserialize()?;
config.source_path.clone_from(&self.config_source);
Ok(config)
}
}
pub(crate) fn server_config_template(trusted: bool) -> &'static str {
if trusted {
OBELISK_TRUSTED_SERVER_TOML
} else {
OBELISK_HELP_SERVER_TOML
}
}
pub(crate) async fn load_deployment_validated(
deployment_toml: &Path,
) -> Result<DeploymentTomlValidated, anyhow::Error> {
let exists = deployment_toml.try_exists().unwrap_or_default();
if !exists {
bail!("cannot find deployment file {deployment_toml:?}");
}
info!("Using deployment file {:?}", deployment_toml);
let deployment_dir = canonicalize_parent(deployment_toml)
.with_context(|| format!("cannot resolve parent of {deployment_toml:?}"))?;
let builder = ConfigBuilder::<AsyncState>::default().add_source(
File::from(deployment_toml)
.required(true)
.format(FileFormat::Toml),
);
let settings = builder.build().await?;
let deployment: DeploymentToml = settings
.try_deserialize()
.with_context(|| format!("cannot parse deployment file {deployment_toml:?}"))?;
deployment
.validate(&deployment_dir)
.with_context(|| format!("cannot validate {deployment_toml:?}"))
}
pub(crate) async fn load_deployment_resolved(
deployment_toml: &Path,
) -> Result<DeploymentResolved, anyhow::Error> {
let mut deployment = load_deployment_validated(deployment_toml)
.await?
.resolve()
.await
.with_context(|| format!("cannot resolve {deployment_toml:?}"))?;
deployment.source_path = Some(deployment_toml.to_path_buf());
Ok(deployment)
}
fn canonicalize_parent(path: &Path) -> Result<PathBuf, anyhow::Error> {
Ok(path
.canonicalize()
.with_context(|| format!("error calling canonicalize on {path:?}"))?
.parent()
.with_context(|| format!("error getting parent path of {path:?}"))?
.to_path_buf())
}
async fn write_config_file(
dst: &Path,
contents: &str,
overwrite: bool,
) -> Result<(), anyhow::Error> {
let mut file = OpenOptions::new()
.write(true)
.create(true) .truncate(true) .create_new(!overwrite) .open(dst)
.await
.with_context(|| {
format!(
"cannot open {dst:?} for writing{}",
if !overwrite {
", try using `--overwrite`"
} else {
""
}
)
})?;
file.write_all(contents.as_bytes())
.await
.with_context(|| format!("cannot write to {dst:?}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{OBELISK_TRUSTED_SERVER_TOML, ServerConfigToml};
use crate::config::toml::{AllowExecActivities, MethodsInput};
#[test]
fn trusted_server_config_allows_exec_and_outbound_http_without_secrets() {
let config: ServerConfigToml = toml::from_str(OBELISK_TRUSTED_SERVER_TOML).unwrap();
assert_eq!(config.allow_exec_activities, AllowExecActivities::AllowAny);
assert!(config.secrets.is_empty());
let [host] = config.outbound_http.allowed_hosts.as_slice() else {
panic!("expected one outbound HTTP host");
};
assert_eq!(host.pattern, "*://*:*");
assert!(matches!(host.methods, Some(MethodsInput::Star(_))));
assert!(host.secrets.is_empty());
assert!(host.replace_in.is_empty());
}
}