use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::stateful::{
self, CacheSpec, ConfigSpec, DatabaseSpec, EmittedEnv, MigrationsSpec, RawCache,
RawConfigBlock, RawDatabase, RawMigrations, RawSecrets, SecretsSpec,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("reading {0}: {1}")]
Io(PathBuf, #[source] std::io::Error),
#[error("parsing {0}: {1}")]
Toml(PathBuf, #[source] toml::de::Error),
#[error(
"{path}: schema = {found:?} is not supported by this CLI. \
Supported schemas: {supported:?}. \
Upgrade the CLI, or set `schema = \"{current}\"` at the top of tonin.toml."
)]
UnsupportedSchema {
path: PathBuf,
found: String,
supported: Vec<String>,
current: String,
},
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mesh {
#[default]
Cilium,
Istio,
Linkerd,
None,
}
impl Mesh {
pub fn as_str(&self) -> &'static str {
match self {
Mesh::Cilium => "cilium",
Mesh::Istio => "istio",
Mesh::Linkerd => "linkerd",
Mesh::None => "none",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ServiceRef {
pub name: String,
pub namespace: String,
}
impl ServiceRef {
pub fn identity(&self) -> String {
format!("{}.{}", self.name, self.namespace)
}
}
pub const CURRENT_SCHEMA: &str = "v1";
pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
schema: Option<String>,
service: RawService,
deploy: RawDeploy,
resources: RawResources,
#[serde(default)]
autoscale: Option<RawAutoscale>,
#[serde(default)]
depends_on: BTreeMap<String, String>,
#[serde(default)]
database: Option<RawDatabase>,
#[serde(default)]
cache: Option<RawCache>,
#[serde(default)]
secrets: Option<RawSecrets>,
#[serde(default)]
migrations: Option<RawMigrations>,
#[serde(default)]
config: Option<RawConfigBlock>,
}
#[derive(Debug, Deserialize)]
struct RawService {
name: String,
version: String,
#[serde(default)]
language: Option<String>,
#[serde(default, rename = "type")]
kind: Option<String>,
#[serde(default)]
web_mode: Option<String>,
#[serde(default)]
#[allow(dead_code)]
codec: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawDeploy {
replicas: u32,
#[serde(default)]
mesh: Option<Mesh>,
#[serde(default = "default_true")]
mcp_sidecar: bool,
namespace: String,
#[serde(default)]
expose: Option<String>, }
#[derive(Debug, Deserialize)]
struct RawResources {
cpu: String,
memory: String,
}
#[derive(Debug, Deserialize)]
struct RawAutoscale {
max_replicas: u32,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
Backend,
Web,
}
impl ServiceKind {
pub fn as_str(&self) -> &'static str {
match self {
ServiceKind::Backend => "backend",
ServiceKind::Web => "web",
}
}
pub fn is_web(&self) -> bool {
matches!(self, ServiceKind::Web)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WebMode {
Spa,
Bff,
}
impl WebMode {
pub fn as_str(&self) -> &'static str {
match self {
WebMode::Spa => "spa",
WebMode::Bff => "bff",
}
}
pub fn container_port(&self) -> u32 {
match self {
WebMode::Spa => 8080,
WebMode::Bff => 3000,
}
}
}
#[derive(Clone, Debug)]
pub struct Plan {
pub name: String,
pub version: String,
pub language: String,
pub kind: ServiceKind,
pub web_mode: Option<WebMode>,
pub namespace: String,
pub mesh: Mesh,
pub replicas: u32,
pub max_replicas: u32,
pub mcp_sidecar: bool,
pub expose: Option<String>,
pub cpu: String,
pub memory: String,
pub image: String,
pub depends_on: Vec<ServiceRef>,
pub callers: Vec<ServiceRef>,
pub dir: PathBuf,
pub database: Option<DatabaseSpec>,
pub cache: Option<CacheSpec>,
pub secrets: Option<SecretsSpec>,
pub migrations: Option<MigrationsSpec>,
pub config: Option<ConfigSpec>,
pub emitted_env: EmittedEnv,
pub selected_env: String,
}
impl Plan {
pub fn load(toml_path: &Path) -> Result<Self, Error> {
Self::load_with_env(toml_path, &stateful::select_env(None))
}
pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
let raw_str = std::fs::read_to_string(toml_path)
.map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
let raw: RawConfig =
toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
if let Some(v) = raw.schema.as_deref()
&& !SUPPORTED_SCHEMAS.contains(&v)
{
return Err(Error::UnsupportedSchema {
path: toml_path.to_path_buf(),
found: v.to_string(),
supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
current: CURRENT_SCHEMA.to_string(),
});
}
let depends_on = raw
.depends_on
.into_iter()
.map(|(name, namespace)| ServiceRef { name, namespace })
.collect();
let max_replicas = raw
.autoscale
.as_ref()
.map(|a| a.max_replicas)
.unwrap_or(raw.deploy.replicas);
let dir = toml_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let image = std::env::var("TONIN_IMAGE_PREFIX")
.map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
.unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
let kind = match raw.service.kind.as_deref() {
Some("web") => ServiceKind::Web,
_ => ServiceKind::Backend,
};
let web_mode = match (kind, raw.service.web_mode.as_deref()) {
(ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
(ServiceKind::Web, _) => Some(WebMode::Spa), _ => None,
};
let svc_name = raw.service.name.clone();
let svc_ns = raw.deploy.namespace.clone();
let database = raw
.database
.as_ref()
.map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
let cache = raw
.cache
.as_ref()
.map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
let config = raw.config.as_ref().map(stateful::resolve_config);
let mut emitted_env = EmittedEnv::default();
if let Some(d) = &database {
emitted_env.extend_database(d, &svc_name);
}
if let Some(c) = &cache {
emitted_env.extend_cache(c);
}
if let Some(s) = &secrets {
emitted_env.extend_secrets(s);
}
Ok(Plan {
name: raw.service.name,
version: raw.service.version,
language: raw.service.language.unwrap_or_else(|| "rust".into()),
kind,
web_mode,
namespace: raw.deploy.namespace,
mesh: raw.deploy.mesh.unwrap_or_default(),
replicas: raw.deploy.replicas,
max_replicas,
mcp_sidecar: raw.deploy.mcp_sidecar,
expose: raw.deploy.expose,
cpu: raw.resources.cpu,
memory: raw.resources.memory,
image,
depends_on,
callers: Vec::new(),
dir,
database,
cache,
secrets,
migrations,
config,
emitted_env,
selected_env: env.to_string(),
})
}
pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
Self::load_workspace_with_env(root, &stateful::select_env(None))
}
pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_name() == "tonin.toml")
.map(|e| Plan::load_with_env(e.path(), env))
.collect::<Result<_, _>>()?;
let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
.iter()
.map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
.collect();
for plan in plans.iter_mut() {
for (caller_name, caller_ns, deps) in &snapshot {
if deps
.iter()
.any(|d| d.name == plan.name && d.namespace == plan.namespace)
{
plan.callers.push(ServiceRef {
name: caller_name.clone(),
namespace: caller_ns.clone(),
});
}
}
plan.callers.sort();
plan.callers.dedup();
}
plans.sort_by(|a, b| a.name.cmp(&b.name));
Ok(plans)
}
}