use crate::cli::{Cli, Commands, DeployCommand};
use crate::db::run_db_command;
use crate::dev::run_dev;
use crate::gateway::run_gateway_command;
use crate::service::run_service_command;
use crate::service_local::setup_current_service;
use crate::util::{Result, make_vars, repo_root, run_cmd, run_make};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::process::Command;
pub(crate) fn run_cli(cli: Cli) -> Result<()> {
match cli.command {
Commands::Service { command } => run_service_command(command),
Commands::Gateway { command } => run_gateway_command(command),
Commands::Db { command } => run_db_command(command),
Commands::Setup(args) => setup_current_service(&args.vars),
Commands::Dev { command } => run_dev(command),
Commands::Sync => run_sync(),
Commands::Release(args) => run_release(&args.vars),
Commands::Publish(args) => run_publish(&args.vars),
Commands::ReleaseSync(args) => run_release_sync(&args.vars),
Commands::Deploy { command } => run_deploy_command(command),
}
}
fn run_deploy_command(command: DeployCommand) -> Result<()> {
match command {
DeployCommand::Kubernetes(args) => {
run_sync()?;
run_make(
&repo_root().join("devops"),
"deploy-kubernetes",
&make_vars(&args.vars),
)
}
DeployCommand::Docker(args) => run_make(
&repo_root().join("devops"),
"deploy-docker",
&make_vars(&args.vars),
),
DeployCommand::Migrations(args) => run_make(
&repo_root().join("devops"),
"apply-migrations",
&make_vars(&args.vars),
),
DeployCommand::ObservabilityUp(args) => run_make(
&repo_root().join("devops"),
"observability-full-up",
&make_vars(&args.vars),
),
DeployCommand::ObservabilityDown(args) => run_make(
&repo_root().join("devops"),
"observability-full-down",
&make_vars(&args.vars),
),
DeployCommand::BackupNow(args) => run_make(
&repo_root().join("devops"),
"backup-now",
&make_vars(&args.vars),
),
DeployCommand::InstallBackupCron(args) => run_make(
&repo_root().join("devops"),
"install-backup-cron",
&make_vars(&args.vars),
),
DeployCommand::ReleaseSync(args) => run_release_sync(&args.vars),
DeployCommand::ReloadCaddy(args) => run_make(
&repo_root().join("devops"),
"reload-caddy",
&make_vars(&args.vars),
),
DeployCommand::Restart(args) => run_make(
&repo_root().join("devops"),
"restart",
&make_vars(&args.vars),
),
DeployCommand::Recreate(args) => run_make(
&repo_root().join("devops"),
"recreate",
&make_vars(&args.vars),
),
}
}
fn run_sync() -> Result<()> {
sync_removed_service_artifacts()?;
run_make(&repo_root().join("services/core/gateway"), "generate", &[])?;
run_docs_api_sync()?;
run_cmd(
&repo_root(),
"devops/droplet/scripts/sync-service-assets.sh",
&[],
)
}
fn sync_removed_service_artifacts() -> Result<()> {
prune_stale_gateway_route_imports()?;
prune_orphan_shared_protobufs()
}
pub(crate) fn prune_stale_gateway_route_imports() -> Result<()> {
let root = repo_root();
let api_dir = root.join("services/core/gateway/api");
let gateway_routes = api_dir.join("gateway-routes.json");
let text = fs::read_to_string(&gateway_routes)?;
let mut document: Value = serde_json::from_str(&text)?;
let Some(imports) = document.get_mut("imports").and_then(Value::as_array_mut) else {
return Ok(());
};
let original_len = imports.len();
let mut kept = Vec::new();
for item in imports.iter() {
let Some(import) = item.as_str() else {
kept.push(item.clone());
continue;
};
let route_file = api_dir.join(import);
if route_import_is_stale(&root, &route_file)? {
println!("[sync] removing stale gateway route import: {import}");
if route_file.starts_with(api_dir.join("routes")) && route_file.exists() {
fs::remove_file(&route_file)?;
}
continue;
}
kept.push(item.clone());
}
*imports = kept;
if imports.len() != original_len {
fs::write(&gateway_routes, gateway_routes_json(&document)?)?;
}
Ok(())
}
fn gateway_routes_json(document: &Value) -> Result<String> {
let default_version = Value::from(1);
let empty_array = Value::Array(vec![]);
Ok(format!(
"{{\n \"version\": {},\n \"imports\": {},\n \"routes\": {},\n \"upstreams\": {},\n \"services\": {},\n \"consumers\": {}\n}}\n",
serde_json::to_string(document.get("version").unwrap_or(&default_version))?,
indent_json(document.get("imports").unwrap_or(&empty_array))?,
indent_json(document.get("routes").unwrap_or(&empty_array))?,
indent_json(document.get("upstreams").unwrap_or(&empty_array))?,
indent_json(document.get("services").unwrap_or(&empty_array))?,
indent_json(document.get("consumers").unwrap_or(&empty_array))?,
))
}
fn indent_json(value: &Value) -> Result<String> {
let text = serde_json::to_string_pretty(value)?;
Ok(text.replace('\n', "\n "))
}
pub(crate) fn route_import_is_stale(root: &Path, route_file: &Path) -> Result<bool> {
if !route_file.exists() {
return Ok(true);
}
let text = fs::read_to_string(route_file)?;
let document: Value = serde_json::from_str(&text)?;
let Some(routes) = document.get("routes").and_then(Value::as_array) else {
return Ok(false);
};
for route in routes {
let Some(proto) = route
.get("target")
.and_then(|target| target.get("proto"))
.and_then(Value::as_str)
else {
continue;
};
if !root.join(proto).exists() {
return Ok(true);
}
}
Ok(false)
}
fn prune_orphan_shared_protobufs() -> Result<()> {
let root = repo_root();
let active_services = active_service_contract_keys(&root)?;
let route_protos = gateway_route_proto_paths(&root)?;
let contracts_dir = root.join("contracts/protobuf");
if !contracts_dir.exists() {
return Ok(());
}
for entry in fs::read_dir(&contracts_dir)? {
let path = entry?.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("proto") {
continue;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if route_protos.contains(&path) {
continue;
}
let Some(key) = file_name.strip_suffix(".proto") else {
continue;
};
if active_services.contains(key) {
continue;
}
println!("[sync] removing orphan shared protobuf: contracts/protobuf/{file_name}");
fs::remove_file(path)?;
}
Ok(())
}
fn active_service_contract_keys(root: &Path) -> Result<HashSet<String>> {
let mut keys = HashSet::new();
collect_active_service_contract_keys(&root.join("services"), &mut keys)?;
Ok(keys)
}
fn collect_active_service_contract_keys(root: &Path, keys: &mut HashSet<String>) -> Result<()> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let path = entry?.path();
if !path.is_dir() {
continue;
}
let service_yaml = path.join("service.yaml");
if service_yaml.exists() {
let text = fs::read_to_string(&service_yaml)?;
if let (Some(domain), Some(name)) = (
simple_yaml_value(&text, "domain"),
simple_yaml_value(&text, "name"),
) {
keys.insert(format!("{domain}-{name}"));
}
} else {
collect_active_service_contract_keys(&path, keys)?;
}
}
Ok(())
}
pub(crate) fn simple_yaml_value(text: &str, key: &str) -> Option<String> {
let prefix = format!("{key}:");
text.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix(&prefix))
.map(|value| value.trim().trim_matches(['"', '\'']).to_string())
.filter(|value| !value.is_empty())
}
fn gateway_route_proto_paths(root: &Path) -> Result<HashSet<std::path::PathBuf>> {
let mut paths = HashSet::new();
collect_gateway_route_proto_paths(
root,
&root.join("services/core/gateway/api/gateway-routes.json"),
&mut paths,
)?;
Ok(paths)
}
fn collect_gateway_route_proto_paths(
root: &Path,
route_source: &Path,
paths: &mut HashSet<std::path::PathBuf>,
) -> Result<()> {
if !route_source.exists() {
return Ok(());
}
let text = fs::read_to_string(route_source)?;
let document: Value = serde_json::from_str(&text)?;
if let Some(imports) = document.get("imports").and_then(Value::as_array) {
let base = route_source.parent().unwrap_or(root);
for import in imports.iter().filter_map(Value::as_str) {
collect_gateway_route_proto_paths(root, &base.join(import), paths)?;
}
}
if let Some(routes) = document.get("routes").and_then(Value::as_array) {
for route in routes {
if let Some(proto) = route
.get("target")
.and_then(|target| target.get("proto"))
.and_then(Value::as_str)
{
paths.insert(root.join(proto));
}
}
}
Ok(())
}
fn run_docs_api_sync() -> Result<()> {
let docs = repo_root().join("docs");
if !docs.join("package.json").exists() {
return Ok(());
}
run_cmd(&docs, "bun", &["run".into(), "sync-swagger-openapi".into()])?;
run_cmd(&docs, "bun", &["run".into(), "generate-api-docs".into()])
}
fn run_release(args: &[String]) -> Result<()> {
run_make(&repo_root().join("devops"), "release", &make_vars(args))
}
fn run_publish(args: &[String]) -> Result<()> {
let manifest = repo_root().join("tools/exe/Cargo.toml");
let max_attempts = publish_max_attempts();
for attempt in 1..=max_attempts {
let version = bump_manifest_patch_version(&manifest)?;
println!("[publish] bumped executesoft crate to {version}");
let output = cargo_publish(&manifest, args)?;
if output.status.success() {
print_command_output(&output);
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
print_command_output(&output);
if stderr.contains("already exists on crates.io index") && attempt < max_attempts {
println!("[publish] version {version} already exists; bumping patch and retrying");
continue;
}
return Err(format!("cargo publish failed with {}", output.status).into());
}
Err("cargo publish retry limit reached".into())
}
fn cargo_publish(manifest: &Path, args: &[String]) -> Result<std::process::Output> {
let mut publish_args = vec![
"publish".to_string(),
"--manifest-path".to_string(),
manifest.display().to_string(),
];
publish_args.extend(args.iter().cloned());
Ok(Command::new("cargo")
.args(&publish_args)
.current_dir(repo_root())
.output()?)
}
fn print_command_output(output: &std::process::Output) {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
fn publish_max_attempts() -> usize {
std::env::var("EXE_PUBLISH_MAX_VERSION_BUMPS")
.ok()
.and_then(|value| value.parse().ok())
.filter(|value| *value > 0)
.unwrap_or(10)
}
pub(crate) fn bump_manifest_patch_version(manifest: &Path) -> Result<String> {
let text = fs::read_to_string(manifest)?;
let mut next_version = None;
let mut updated = Vec::new();
let mut in_package = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
}
if in_package
&& trimmed.starts_with("version")
&& let Some((key, value)) = line.split_once('=')
{
let current = value.trim().trim_matches('"');
let next = next_patch_version(current)?;
next_version = Some(next.clone());
updated.push(format!("{}= \"{}\"", key, next));
continue;
}
updated.push(line.to_string());
}
let Some(version) = next_version else {
return Err(format!("version not found in {}", manifest.display()).into());
};
fs::write(manifest, format!("{}\n", updated.join("\n")))?;
Ok(version)
}
fn next_patch_version(version: &str) -> Result<String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return Err(format!("expected semver major.minor.patch, got {version}").into());
}
let major: u64 = parts[0].parse()?;
let minor: u64 = parts[1].parse()?;
let patch: u64 = parts[2].parse()?;
Ok(format!("{major}.{minor}.{}", patch + 1))
}
fn run_release_sync(args: &[String]) -> Result<()> {
run_make(
&repo_root().join("devops"),
"release-sync",
&make_vars(args),
)
}