use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
constants::RELEASE_MANIFEST_SCHEMA_VERSION,
core::{
ast::infrastructure::{
compliance::scan_all_compliance, integrations::Integration,
worker_config::WorkerConfig as AstWorkerConfig,
},
library_scanner::{
ImportScanner, LibraryDefinition, Topology, parse_route_file, scan_project_libraries,
},
manifest::{
ProjectEntry, ProjectLanguage, ProjectType, ResourceInventory,
application::ApplicationManifestData,
},
sync::detection::detect_routers_from_service,
},
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum EnvironmentVariableScope {
Application,
Service,
Worker,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum EnvironmentVariableComponentType {
Database,
Cache,
#[serde(rename = "object_store")]
ObjectStore,
Queue,
Service,
Worker,
Key,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum EnvironmentVariableComponentProperty {
Host,
Hostname,
Port,
Url,
Connection,
#[serde(rename = "connection_string")]
ConnectionString,
Username,
User,
Password,
Database,
DbName,
Fqdn,
Bucket,
Endpoint,
Region,
#[serde(rename = "private-pem")]
PrivatePem,
#[serde(rename = "public-pem")]
PublicPem,
#[serde(rename = "32-bytes-base64")]
Base64Bytes32,
#[serde(rename = "64-bytes-base64")]
Base64Bytes64,
#[serde(rename = "hex-key")]
HexKey,
#[serde(rename = "key-material")]
KeyMaterial,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ConfigType {
Service,
Worker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum WorkerType {
Kafka,
Redis,
BullMQ,
#[serde(rename = "postgresql")]
PostgreSQL,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Runtime {
Node,
Bun,
Tokio,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct EnvironmentVariableComponent {
pub r#type: EnvironmentVariableComponentType,
pub property: EnvironmentVariableComponentProperty,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub passthrough: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct InterServiceUrlInfo {
#[serde(rename = "targetService")]
pub target_service: String,
pub transport: String,
#[serde(rename = "portEnvVar")]
pub port_env_var: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct EnvironmentVariableRequirement {
pub name: String,
pub scope: EnvironmentVariableScope,
#[serde(rename = "scopeId", skip_serializing_if = "Option::is_none")]
pub scope_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<EnvironmentVariableComponent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(rename = "interServiceUrl", skip_serializing_if = "Option::is_none")]
pub inter_service_url: Option<InterServiceUrlInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optional: Option<bool>,
#[serde(rename = "usedBy", skip_serializing_if = "Vec::is_empty", default)]
pub used_by: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ReleaseManifest {
#[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
#[serde(rename = "applicationId")]
pub application_id: String,
#[serde(rename = "applicationName", skip_serializing_if = "Option::is_none")]
pub application_name: Option<String>,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(rename = "gitCommit")]
pub git_commit: String,
#[serde(rename = "gitBranch", skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(rename = "gitRepository", skip_serializing_if = "Option::is_none")]
pub git_repository: Option<String>,
#[serde(rename = "codeSourceUrl", skip_serializing_if = "Option::is_none")]
pub code_source_url: Option<String>,
pub timestamp: String,
pub services: Vec<ServiceDefinition>,
pub infrastructure: InfrastructureConfig,
#[serde(
rename = "environmentVariables",
skip_serializing_if = "Option::is_none"
)]
pub environment_variables: Option<EnvironmentVariables>,
#[serde(
rename = "requiredEnvironmentVariables",
skip_serializing_if = "Option::is_none"
)]
pub required_environment_variables: Option<Vec<EnvironmentVariableRequirement>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub libraries: Option<Vec<LibraryDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compliance: Option<ReleaseComplianceData>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseComplianceData {
pub entities: Vec<ReleaseEntityCompliance>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub data_residency: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseEntityCompliance {
pub name: String,
pub fields: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention: Option<ReleaseRetentionConfig>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ReleaseRetentionConfig {
pub duration: String,
pub action: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ServiceDefinition {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub config: ServiceConfigEnum,
#[serde(rename = "buildContext", skip_serializing_if = "Option::is_none")]
pub build_context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub binary: Option<String>,
#[serde(rename = "nativeWorkerRole", skip_serializing_if = "Option::is_none")]
pub native_worker_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub migrate: Option<NativeMigration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct NativeMigration {
pub binary: String,
pub args: Vec<String>,
#[serde(rename = "databaseUrlEnv")]
pub database_url_env: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DependencyDefinition {
pub name: String,
#[serde(rename = "type")]
pub dependency_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum ServiceConfigEnum {
Service(ServiceConfig),
Worker(WorkerConfig),
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ServiceConfig {
#[serde(rename = "type")]
pub service_type: ConfigType,
#[serde(skip_serializing_if = "Option::is_none")]
pub controllers: Option<Vec<ControllerDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Vec<IntegrationDefinition>>,
#[serde(rename = "openApiSpec", skip_serializing_if = "Option::is_none")]
pub open_api_spec: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Vec<DependencyDefinition>>,
#[serde(
rename = "runtimeDependencies",
skip_serializing_if = "Option::is_none"
)]
pub runtime_dependencies: Option<Vec<String>>,
#[serde(rename = "instanceSize", skip_serializing_if = "Option::is_none")]
pub instance_size: Option<String>,
#[serde(rename = "hostingType", skip_serializing_if = "Option::is_none")]
pub hosting_type: Option<String>,
#[serde(rename = "healthCheck", skip_serializing_if = "Option::is_none")]
pub health_check: Option<Value>,
#[serde(rename = "isWorkerService", skip_serializing_if = "Option::is_none")]
pub is_worker_service: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub privileged: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serves: Option<Vec<ServingPortDefinition>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct ServingPortDefinition {
pub protocol: String,
#[serde(rename = "portEnv")]
pub port_env: String,
#[serde(rename = "healthPath", skip_serializing_if = "Option::is_none")]
pub health_path: Option<String>,
}
impl From<&crate::core::manifest::ServingPort> for ServingPortDefinition {
fn from(port: &crate::core::manifest::ServingPort) -> Self {
Self {
protocol: port.protocol.clone(),
port_env: port.port_env.clone(),
health_path: port.health_path.clone(),
}
}
}
fn serving_ports(
project: &crate::core::manifest::ProjectEntry,
) -> Option<Vec<ServingPortDefinition>> {
project
.serves
.as_ref()
.filter(|ports| !ports.is_empty())
.map(|ports| ports.iter().map(ServingPortDefinition::from).collect())
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct WorkerConfig {
#[serde(rename = "type")]
pub config_type: ConfigType,
#[serde(rename = "workerType")]
pub worker_type: WorkerType,
#[serde(skip_serializing_if = "Option::is_none")]
pub concurrency: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<i32>,
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
pub max_retries: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(rename = "deadLetterQueue", skip_serializing_if = "Option::is_none")]
pub dead_letter_queue: Option<bool>,
#[serde(flatten)]
pub additional: Option<HashMap<String, Value>>,
#[serde(
rename = "runtimeDependencies",
skip_serializing_if = "Option::is_none"
)]
pub runtime_dependencies: Option<Vec<String>>,
#[serde(rename = "instanceSize", skip_serializing_if = "Option::is_none")]
pub instance_size: Option<String>,
#[serde(rename = "hostingType", skip_serializing_if = "Option::is_none")]
pub hosting_type: Option<String>,
#[serde(rename = "healthCheck", skip_serializing_if = "Option::is_none")]
pub health_check: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub privileged: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serves: Option<Vec<ServingPortDefinition>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ControllerDefinition {
pub id: String,
pub name: String,
pub path: String,
pub routes: Vec<RouteDefinition>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct RouteDefinition {
pub id: String,
pub method: String,
pub path: String,
pub handler: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub middleware: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub topology: Option<Topology>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct IntegrationDefinition {
pub id: String,
#[serde(rename = "type")]
pub integration_type: String,
pub config: HashMap<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct InfrastructureConfig {
pub regions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<Vec<ResourceDefinition>>,
#[serde(rename = "cloudProvider", skip_serializing_if = "Option::is_none")]
pub cloud_provider: Option<String>,
#[serde(rename = "vpcId", skip_serializing_if = "Option::is_none")]
pub vpc_id: Option<String>,
#[serde(
rename = "defaultInstanceSize",
skip_serializing_if = "Option::is_none"
)]
pub default_instance_size: Option<String>,
#[serde(rename = "buildContext", skip_serializing_if = "Option::is_none")]
pub build_context: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ResourceDefinition {
pub id: String,
#[serde(rename = "type")]
pub resource_type: String,
pub name: String,
pub technology: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<HashMap<String, Value>>,
#[serde(rename = "serviceName", skip_serializing_if = "Option::is_none")]
pub service_name: Option<String>,
}
pub(crate) type EnvironmentVariables = HashMap<String, HashMap<String, Vec<EnvironmentVariable>>>;
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct EnvironmentVariable {
pub key: String,
pub value: String,
}
fn application_uses_rust(manifest: &ApplicationManifestData) -> bool {
manifest.runtime == "tokio"
|| manifest.language.as_deref() == Some("rust")
|| manifest.language.as_deref() == Some("mixed")
|| manifest.application_language.as_deref() == Some("rust")
|| manifest.application_language.as_deref() == Some("mixed")
|| manifest.cargo_workspace.is_some()
}
fn project_language(project: &ProjectEntry) -> ProjectLanguage {
if project
.metadata
.as_ref()
.and_then(|metadata| metadata.language.as_deref())
== Some("rust")
{
ProjectLanguage::Rust
} else {
ProjectLanguage::TypeScript
}
}
fn project_is_rust(project: &ProjectEntry) -> bool {
project_language(project) == ProjectLanguage::Rust
}
fn project_native_migration(project: &ProjectEntry, binary: Option<String>) -> Option<NativeMigration> {
let database = project.resources.as_ref().and_then(|resources| resources.database.as_deref());
if !project_is_rust(project) || database.is_none_or(|database| database != "postgresql") {
return None;
}
let variant = project.variant.as_deref().unwrap_or_default();
let database_url_env = match (&project.r#type, variant) {
(ProjectType::Service, "") => "MIGRATION_DATABASE_URL",
(ProjectType::Service, variant) if variant.starts_with("iam") || variant.starts_with("billing") || variant.starts_with("ecommerce") => "DATABASE_URL",
(ProjectType::Worker, _) => "DATABASE_URL",
_ => return None,
};
Some(NativeMigration { binary: binary?, args: vec!["--migrate".into()], database_url_env: database_url_env.into() })
}
fn project_native_binary(project: &ProjectEntry) -> Option<String> {
project_is_rust(project).then(|| {
project
.metadata
.as_ref()
.and_then(|metadata| metadata.crate_name.clone())
.unwrap_or_else(|| project.name.clone())
})
}
fn project_relative_path(manifest: &ApplicationManifestData, project: &ProjectEntry) -> String {
if crate::core::storefront::is_capture(project) {
return project
.metadata
.as_ref()
.and_then(|metadata| metadata.static_path.clone())
.unwrap_or_else(|| {
Path::new(&manifest.modules_path)
.join(&project.name)
.to_string_lossy()
.into_owned()
});
}
if project_is_rust(project) {
project
.metadata
.as_ref()
.and_then(|metadata| metadata.crate_path.clone())
.unwrap_or_else(|| {
Path::new("crates")
.join(&project.name)
.to_string_lossy()
.into()
})
} else {
Path::new(&manifest.modules_path)
.join(&project.name)
.to_string_lossy()
.into_owned()
}
}
fn project_abs_path(
app_root: &Path,
manifest: &ApplicationManifestData,
project: &ProjectEntry,
) -> PathBuf {
app_root.join(project_relative_path(manifest, project))
}
fn compose_project_build(
app_root: &Path,
manifest: &ApplicationManifestData,
project: &ProjectEntry,
) -> Option<(String, String, Option<String>)> {
if project_abs_path(app_root, manifest, project)
.join("Dockerfile")
.is_file()
|| manifest
.dockerfile
.as_deref()
.is_some_and(|path| !matches!(path, "Dockerfile" | "./Dockerfile"))
{
return None;
}
let path = app_root.join(
manifest
.docker_compose_path
.as_deref()
.unwrap_or("docker-compose.yaml"),
);
let compose: serde_yml::Value =
serde_yml::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
let services = compose.get("services")?;
let service = services
.get(&project.name)
.or_else(|| services.get(format!("{}-service", project.name)))
.or_else(|| services.get(format!("{}-worker", project.name)))?;
let build = service.get("build").or_else(|| {
let image = service.get("image")?.as_str()?;
services.as_mapping()?.values().find_map(|candidate| {
(candidate.get("image").and_then(serde_yml::Value::as_str) == Some(image))
.then(|| candidate.get("build"))
.flatten()
})
})?;
let context = build.get("context")?.as_str()?;
let dockerfile = build
.get("dockerfile")
.and_then(serde_yml::Value::as_str)
.unwrap_or("Dockerfile");
let absolute = path.parent()?.join(context);
let relative = pathdiff::diff_paths(&absolute, app_root)?;
let context = if relative.as_os_str().is_empty() {
".".into()
} else {
relative.to_string_lossy().into_owned()
};
Some((
context,
dockerfile.into(),
build
.get("target")
.and_then(serde_yml::Value::as_str)
.map(str::to_owned),
))
}
fn check_release_build_targets(app_root: &Path, manifest: &ApplicationManifestData) -> Result<()> {
for project in &manifest.projects {
if let Some((_, _, Some(target))) = compose_project_build(app_root, manifest, project) {
anyhow::bail!(
"Release build targets are not supported by the release manifest contract: project {} selects Compose target {target}; local Docker Compose remains supported",
project.name
);
}
}
Ok(())
}
fn project_dockerfile(
app_root: &Path,
manifest: &ApplicationManifestData,
project: &ProjectEntry,
) -> String {
if project_abs_path(app_root, manifest, project)
.join("Dockerfile")
.is_file()
{
return "Dockerfile".to_string();
}
if let Some(custom) = manifest
.dockerfile
.as_deref()
.filter(|path| !matches!(*path, "Dockerfile" | "./Dockerfile"))
{
return custom.into();
}
if let Some((_, file, _)) = compose_project_build(app_root, manifest, project) {
return file;
}
if !project_is_rust(project) && app_root.join("Dockerfile.typescript").is_file() {
return pathdiff::diff_paths(
app_root.join("Dockerfile.typescript"),
app_root.join(&manifest.modules_path),
)
.unwrap()
.to_string_lossy()
.into_owned();
}
if project_is_rust(project)
&& (matches!(manifest.runtime.as_str(), "node" | "bun")
|| (manifest.language.as_deref() == Some("mixed")
&& app_root.join("Dockerfile.rust").is_file()))
{
return "Dockerfile.rust".to_string();
}
manifest
.dockerfile
.clone()
.unwrap_or_else(|| "Dockerfile".to_string())
}
fn project_build_context(
app_root: &Path,
manifest: &ApplicationManifestData,
project: &ProjectEntry,
) -> Option<String> {
let project_path = project_relative_path(manifest, project);
if app_root.join(&project_path).join("Dockerfile").exists() {
return Some(project_path);
}
if let Some((context, _, _)) = compose_project_build(app_root, manifest, project) {
return Some(context);
}
if project_is_rust(project) {
let dockerfile = project_dockerfile(app_root, manifest, project);
if app_root.join(dockerfile).exists() {
return Some(".".to_string());
}
}
Some(manifest.modules_path.clone())
}
fn project_native_worker_role(project: &ProjectEntry, role: &str) -> Option<String> {
if !project_is_rust(project) { return None; }
let metadata = project.metadata.as_ref()?;
if metadata.worker_service == Some(true) { return None; }
(metadata.worker_control_service == Some(true) && role == "control").then(|| "--control-service".to_owned())
}
fn project_worker_consumer_binary(project: &ProjectEntry) -> Option<String> {
project_native_binary(project).map(|binary| {
if project.metadata.as_ref().and_then(|m| m.worker_service) == Some(true) {
format!("{binary}-worker")
} else { binary }
})
}
fn project_worker_type(project: &ProjectEntry) -> String {
project
.metadata
.as_ref()
.and_then(|metadata| {
metadata
.worker_type
.clone()
.or_else(|| metadata.r#type.clone())
})
.unwrap_or_else(|| "bullmq".to_string())
}
pub(crate) fn generate_release_manifest(
app_root: &Path,
application_id: String,
version: String,
git_commit: String,
git_branch: Option<String>,
code_source_url: Option<String>,
manifest: &ApplicationManifestData,
openapi_specs: &HashMap<String, Value>,
required_env_vars: Vec<EnvironmentVariableRequirement>,
project_runtime_deps: &HashMap<String, Vec<String>>,
project_integrations: &HashMap<String, Vec<Integration>>,
worker_configs: &HashMap<String, AstWorkerConfig>,
service_dependencies: &HashMap<String, Vec<(String, String)>>,
openapi_s3_keys: &HashMap<String, String>,
user_managed_db_projects: &std::collections::HashSet<String>,
) -> Result<ReleaseManifest> {
let normalized = crate::core::manifest::language::normalized(app_root, manifest)?;
let manifest = &normalized;
check_release_build_targets(app_root, manifest)?;
let timestamp = chrono::Utc::now().to_rfc3339();
let modules_root = app_root.join(&manifest.modules_path);
let package_json_path = app_root.join("package.json");
let mut import_scanner = ImportScanner::with_app_root(
&modules_root,
&package_json_path,
&manifest.app_name,
app_root,
);
let mut services = Vec::new();
for project in &manifest.projects {
if project.r#type == ProjectType::Service {
let open_api_spec = if let Some(s3_key) = openapi_s3_keys.get(&project.name) {
Some(Value::String(s3_key.clone()))
} else {
openapi_specs.get(&project.name).cloned()
};
let runtime_deps = project_runtime_deps.get(&project.name).cloned();
let deps = service_dependencies.get(&project.name).map(|dep_tuples| {
dep_tuples
.iter()
.map(|(name, dep_type)| DependencyDefinition {
name: name.clone(),
dependency_type: dep_type.clone(),
})
.collect::<Vec<_>>()
});
let integrations = project_integrations.get(&project.name).map(|integrations| {
integrations
.iter()
.map(|integration| IntegrationDefinition {
id: integration.id.clone(),
integration_type: integration.integration_type.clone(),
config: integration.config.clone(),
status: None,
})
.collect()
});
let service_path = project_abs_path(app_root, manifest, project);
let controllers = if service_path.join("api").join("routes").exists() {
if let Ok(routers) = detect_routers_from_service(&service_path) {
if !routers.is_empty() {
Some(
routers
.into_iter()
.map(|router_name| {
let route_file = service_path
.join("api")
.join("routes")
.join(format!("{}.routes.ts", router_name));
let (parsed_routes, handler_sources) =
parse_route_file(&route_file, &modules_root)
.unwrap_or_default();
let routes: Vec<RouteDefinition> = parsed_routes
.into_iter()
.map(|parsed| {
let topology = handler_sources
.get(&parsed.handler)
.and_then(|source_path| {
import_scanner.scan(source_path).ok()
});
RouteDefinition {
id: format!(
"{}-{}",
parsed.method.to_lowercase(),
parsed
.path
.replace('/', "-")
.trim_matches('-')
.to_string()
),
method: parsed.method,
path: parsed.path,
handler: parsed.handler,
middleware: None,
schema: None,
auth: None,
topology,
}
})
.collect();
ControllerDefinition {
id: format!(
"{}-controller",
router_name
.to_lowercase()
.trim_end_matches("controller")
),
name: router_name.clone(),
path: format!("/{}", router_name),
routes,
}
})
.collect(),
)
} else {
None
}
} else {
None
}
} else {
None
};
services.push(ServiceDefinition {
id: project.name.clone(),
name: project.name.clone(),
status: None,
config: ServiceConfigEnum::Service(ServiceConfig {
service_type: ConfigType::Service,
controllers,
integrations,
open_api_spec,
dependencies: deps,
runtime_dependencies: runtime_deps,
instance_size: None,
hosting_type: project
.metadata
.as_ref()
.and_then(|m| m.hosting_type.clone()),
health_check: None,
is_worker_service: None,
privileged: project.metadata.as_ref().and_then(|m| m.privileged),
serves: serving_ports(project),
}),
build_context: project_build_context(app_root, manifest, project),
dockerfile: Some(project_dockerfile(app_root, manifest, project)),
runtime: project_is_rust(project).then_some(Runtime::Tokio),
binary: project_native_binary(project),
native_worker_role: None,
migrate: project_native_migration(project, project_native_binary(project)),
variant: project.variant.clone(),
});
} else if project.r#type == ProjectType::Worker {
let runtime_deps = project_runtime_deps.get(&project.name).cloned();
let deps = service_dependencies.get(&project.name).map(|dep_tuples| {
dep_tuples
.iter()
.map(|(name, dep_type)| DependencyDefinition {
name: name.clone(),
dependency_type: dep_type.clone(),
})
.collect::<Vec<_>>()
});
let integrations = project_integrations.get(&project.name).map(|integrations| {
integrations
.iter()
.map(|integration| IntegrationDefinition {
id: integration.id.clone(),
integration_type: integration.integration_type.clone(),
config: integration.config.clone(),
status: None,
})
.collect()
});
let open_api_spec = if let Some(s3_key) = openapi_s3_keys.get(&project.name) {
Some(Value::String(s3_key.clone()))
} else {
openapi_specs.get(&project.name).cloned()
};
let worker_path = project_abs_path(app_root, manifest, project);
let controllers = if worker_path.join("api").join("routes").exists() {
if let Ok(routers) = detect_routers_from_service(&worker_path) {
if !routers.is_empty() {
Some(
routers
.into_iter()
.map(|router_name| {
let route_file = worker_path
.join("api")
.join("routes")
.join(format!("{}.routes.ts", router_name));
let (parsed_routes, handler_sources) =
parse_route_file(&route_file, &modules_root)
.unwrap_or_default();
let routes: Vec<RouteDefinition> = parsed_routes
.into_iter()
.map(|parsed| {
let topology = handler_sources
.get(&parsed.handler)
.and_then(|source_path| {
import_scanner.scan(source_path).ok()
});
RouteDefinition {
id: format!(
"{}-{}",
parsed.method.to_lowercase(),
parsed
.path
.replace('/', "-")
.trim_matches('-')
.to_string()
),
method: parsed.method,
path: parsed.path,
handler: parsed.handler,
middleware: None,
schema: None,
auth: None,
topology,
}
})
.collect();
ControllerDefinition {
id: format!(
"{}-controller",
router_name
.to_lowercase()
.trim_end_matches("controller")
),
name: router_name.clone(),
path: format!("/{}", router_name),
routes,
}
})
.collect(),
)
} else {
None
}
} else {
None
}
} else {
None
};
if project.metadata.as_ref().and_then(|m| m.worker_service) != Some(false) {
services.push(ServiceDefinition {
id: format!("{}-service", project.name),
name: format!("{}-service", project.name),
status: None,
config: ServiceConfigEnum::Service(ServiceConfig {
service_type: ConfigType::Service,
controllers,
integrations: integrations.as_ref().map(
|integrations: &Vec<IntegrationDefinition>| {
integrations
.iter()
.map(|integration| IntegrationDefinition {
id: integration.id.clone(),
integration_type: integration.integration_type.clone(),
config: integration.config.clone(),
status: integration.status.clone(),
})
.collect::<Vec<IntegrationDefinition>>()
},
),
open_api_spec: open_api_spec.clone(),
dependencies: deps.clone(),
runtime_dependencies: runtime_deps.clone(),
instance_size: None,
hosting_type: project
.metadata
.as_ref()
.and_then(|m| m.hosting_type.clone()),
health_check: None,
is_worker_service: Some(true),
privileged: project.metadata.as_ref().and_then(|m| m.privileged),
serves: serving_ports(project),
}),
build_context: project_build_context(app_root, manifest, project),
dockerfile: Some(project_dockerfile(app_root, manifest, project)),
runtime: project_is_rust(project).then_some(Runtime::Tokio),
binary: project_native_binary(project),
native_worker_role: project_native_worker_role(project, "control"),
migrate: None,
variant: project.variant.clone(),
});
}
let worker_type_str = project_worker_type(project);
let worker_type = match worker_type_str.as_str() {
"kafka" => WorkerType::Kafka,
"redis" => WorkerType::Redis,
"database" | "postgresql" => WorkerType::PostgreSQL,
_ => WorkerType::BullMQ,
};
let extracted_config = worker_configs.get(&project.name);
let worker_config = WorkerConfig {
config_type: ConfigType::Worker,
worker_type,
concurrency: extracted_config.and_then(|c| c.concurrency),
timeout: extracted_config.and_then(|c| c.timeout),
max_retries: extracted_config.and_then(|c| c.max_retries),
queue: extracted_config.and_then(|c| c.queue.clone()),
priority: extracted_config.and_then(|c| c.priority.clone()),
dead_letter_queue: extracted_config.and_then(|c| c.dead_letter_queue),
additional: None,
runtime_dependencies: runtime_deps,
instance_size: None,
hosting_type: project
.metadata
.as_ref()
.and_then(|m| m.hosting_type.clone()),
health_check: None,
privileged: project.metadata.as_ref().and_then(|m| m.privileged),
serves: serving_ports(project),
};
services.push(ServiceDefinition {
id: format!("{}-worker", project.name),
name: format!("{}-worker", project.name),
status: None,
config: ServiceConfigEnum::Worker(worker_config),
build_context: project_build_context(app_root, manifest, project),
dockerfile: Some(project_dockerfile(app_root, manifest, project)),
runtime: project_is_rust(project).then_some(Runtime::Tokio),
binary: project_worker_consumer_binary(project),
native_worker_role: project_native_worker_role(project, "consumer"),
migrate: project_native_migration(project, project_worker_consumer_binary(project)),
variant: project.variant.clone(),
});
}
}
let mut resources = Vec::new();
for project in &manifest.projects {
if let Some(project_resources) = &project.resources {
let user_managed_db = user_managed_db_projects.contains(&project.name);
if project.r#type == ProjectType::Worker {
if user_managed_db {
add_resources_from_inventory(
&project.name,
project_resources,
&mut resources,
true,
);
let service_name = format!("{}-service", project.name);
add_non_db_resources(&service_name, project_resources, &mut resources);
let worker_name = format!("{}-worker", project.name);
add_non_db_resources(&worker_name, project_resources, &mut resources);
} else {
let service_name = format!("{}-service", project.name);
add_resources_from_inventory(
&service_name,
project_resources,
&mut resources,
false,
);
let worker_name = format!("{}-worker", project.name);
add_resources_from_inventory(
&worker_name,
project_resources,
&mut resources,
false,
);
}
} else {
add_resources_from_inventory(
&project.name,
project_resources,
&mut resources,
user_managed_db,
);
}
}
}
let infrastructure = InfrastructureConfig {
regions: vec![],
resources: if resources.is_empty() {
None
} else {
Some(resources)
},
cloud_provider: Some("aws".to_string()),
vpc_id: None,
default_instance_size: None,
build_context: if application_uses_rust(manifest)
&& manifest
.dockerfile
.as_deref()
.map(|dockerfile| app_root.join(dockerfile).exists())
.unwrap_or_else(|| app_root.join("Dockerfile").exists())
{
Some(".".to_string())
} else if app_root
.join(&manifest.modules_path)
.join("Dockerfile")
.exists()
{
Some(manifest.modules_path.clone())
} else {
None
},
};
let runtime = match manifest.runtime.as_str() {
"node" => Some(Runtime::Node),
"bun" => Some(Runtime::Bun),
"tokio" => Some(Runtime::Tokio),
_ => None,
};
let compliance_data = scan_all_compliance(&modules_root).ok().map(
|(field_classifications, retention_policies)| {
let compliance_config = manifest.compliance.as_ref();
let mut entity_names: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
entity_names.extend(field_classifications.keys().cloned());
entity_names.extend(retention_policies.keys().cloned());
let entities: Vec<ReleaseEntityCompliance> = entity_names
.into_iter()
.map(|name| {
let fields = field_classifications
.get(&name)
.cloned()
.unwrap_or_default();
let retention = retention_policies
.get(&name)
.map(|r| ReleaseRetentionConfig {
duration: r.duration.clone(),
action: r.action.clone(),
});
ReleaseEntityCompliance {
name,
fields,
retention,
}
})
.collect();
ReleaseComplianceData {
entities,
data_residency: compliance_config
.map(|c| c.data_residency.clone())
.unwrap_or_default(),
secrets: compliance_config
.map(|c| c.secrets.clone())
.unwrap_or_default(),
}
},
);
Ok(ReleaseManifest {
schema_version: Some(RELEASE_MANIFEST_SCHEMA_VERSION.to_string()),
application_id,
application_name: Some(manifest.app_name.clone()),
version,
runtime,
git_commit,
git_branch,
git_repository: manifest.git_repository.clone(),
code_source_url,
timestamp,
services,
infrastructure,
environment_variables: None,
required_environment_variables: if required_env_vars.is_empty() {
None
} else {
Some(required_env_vars)
},
libraries: scan_project_libraries(&app_root.join("package.json")).ok(),
compliance: compliance_data,
})
}
fn add_capability_resources(
service_name: &str,
inventory: &ResourceInventory,
resources: &mut Vec<ResourceDefinition>,
) {
for capability in inventory.capabilities.iter().flatten() {
let resource_type = crate::infra::capabilities::resource_type(capability);
resources.push(ResourceDefinition {
id: format!("{service_name}-{capability}"),
resource_type: resource_type.to_string(),
name: format!("{service_name}-{capability}"),
technology: "platform-gateway".to_string(),
region: None,
config: None,
service_name: Some(service_name.to_string()),
});
}
}
fn add_resources_from_inventory(
service_name: &str,
inventory: &ResourceInventory,
resources: &mut Vec<ResourceDefinition>,
user_managed_db: bool,
) {
if let Some(database) = &inventory.database {
let mut config = HashMap::new();
config.insert("mode".to_string(), Value::String("centralized".to_string()));
resources.push(ResourceDefinition {
id: format!("{}-db", service_name),
resource_type: "database".to_string(),
name: format!("{}-database", service_name),
technology: database.clone(),
region: None,
config: Some(config),
service_name: if user_managed_db {
None
} else {
Some(service_name.to_string())
},
});
}
if let Some(cache) = &inventory.cache {
let mut config = HashMap::new();
config.insert("mode".to_string(), Value::String("distributed".to_string()));
resources.push(ResourceDefinition {
id: format!("{}-cache", service_name),
resource_type: "cache".to_string(),
name: format!("{}-cache", service_name),
technology: cache.clone(),
region: None,
config: Some(config),
service_name: Some(service_name.to_string()),
});
}
if let Some(queue) = &inventory.queue {
let mut config = HashMap::new();
config.insert("mode".to_string(), Value::String("distributed".to_string()));
resources.push(ResourceDefinition {
id: format!("{}-queue", service_name),
resource_type: "messagequeue".to_string(),
name: format!("{}-queue", service_name),
technology: queue.clone(),
region: None,
config: Some(config),
service_name: Some(service_name.to_string()),
});
}
add_capability_resources(service_name, inventory, resources);
if let Some(object_store) = &inventory.object_store {
resources.push(ResourceDefinition {
id: format!("{}-storage", service_name),
resource_type: "objectstore".to_string(),
name: format!("{}-storage", service_name),
technology: object_store.clone(),
region: None,
config: None,
service_name: Some(service_name.to_string()),
});
}
}
fn add_non_db_resources(
service_name: &str,
inventory: &ResourceInventory,
resources: &mut Vec<ResourceDefinition>,
) {
if let Some(cache) = &inventory.cache {
let mut config = HashMap::new();
config.insert("mode".to_string(), Value::String("distributed".to_string()));
resources.push(ResourceDefinition {
id: format!("{}-cache", service_name),
resource_type: "cache".to_string(),
name: format!("{}-cache", service_name),
technology: cache.clone(),
region: None,
config: Some(config),
service_name: Some(service_name.to_string()),
});
}
if let Some(queue) = &inventory.queue {
let mut config = HashMap::new();
config.insert("mode".to_string(), Value::String("distributed".to_string()));
resources.push(ResourceDefinition {
id: format!("{}-queue", service_name),
resource_type: "messagequeue".to_string(),
name: format!("{}-queue", service_name),
technology: queue.clone(),
region: None,
config: Some(config),
service_name: Some(service_name.to_string()),
});
}
add_capability_resources(service_name, inventory, resources);
if let Some(object_store) = &inventory.object_store {
resources.push(ResourceDefinition {
id: format!("{}-storage", service_name),
resource_type: "objectstore".to_string(),
name: format!("{}-storage", service_name),
technology: object_store.clone(),
region: None,
config: None,
service_name: Some(service_name.to_string()),
});
}
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use tempfile::TempDir;
use super::*;
use crate::core::manifest::{ProjectMetadata, ResourceInventory};
fn rust_release_test_manifest() -> ApplicationManifestData {
ApplicationManifestData {
id: "rust-test-id".to_string(),
cli_version: "1.0.0".to_string(),
app_name: "rust-test".to_string(),
camel_case_app_name: "rustTest".to_string(),
pascal_case_app_name: "RustTest".to_string(),
kebab_case_app_name: "rust-test".to_string(),
title_case_app_name: "Rust Test".to_string(),
modules_path: "crates".to_string(),
docker_compose_path: Some("docker-compose.yaml".to_string()),
dockerfile: Some("Dockerfile".to_string()),
git_repository: None,
runtime: "tokio".to_string(),
formatter: "rustfmt".to_string(),
linter: "clippy".to_string(),
validator: "serde".to_string(),
http_framework: "axum".to_string(),
test_framework: Some("cargo-test".to_string()),
app_description: "Rust test application".to_string(),
author: "Test Author".to_string(),
license: "MIT".to_string(),
language: Some("rust".to_string()),
application_language: Some("rust".to_string()),
sdk_targets: vec!["typescript".to_string(), "rust".to_string()],
cargo_workspace: Some("Cargo.toml".to_string()),
contract_artifact_path: None,
openapi_path: None,
asyncapi_path: None,
projects: vec![
ProjectEntry {
r#type: ProjectType::Service,
name: "accounts".to_string(),
description: "Accounts service".to_string(),
variant: None,
serves: None,
resources: Some(ResourceInventory {
database: Some("postgresql".to_string()),
cache: None,
queue: None,
object_store: None,
redis_partition: None,
capabilities: None,
}),
routers: Some(vec!["users".to_string()]),
metadata: Some(ProjectMetadata {
language: Some("rust".to_string()),
crate_name: Some("accounts".to_string()),
crate_path: Some("crates/accounts".to_string()),
openapi_path: Some(
"crates/accounts/sdk/typescript/openapi.json".to_string(),
),
..ProjectMetadata::default()
}),
},
ProjectEntry {
r#type: ProjectType::Worker,
name: "email-worker".to_string(),
description: "Email worker".to_string(),
variant: None,
serves: None,
resources: Some(ResourceInventory {
database: Some("postgresql".to_string()),
cache: None,
queue: None,
object_store: None,
redis_partition: None,
capabilities: None,
}),
routers: None,
metadata: Some(ProjectMetadata {
language: Some("rust".to_string()),
crate_name: Some("email-worker".to_string()),
crate_path: Some("crates/email-worker".to_string()),
worker_type: Some("database".to_string()),
openapi_path: Some(
"crates/email-worker/sdk/typescript/openapi.json".to_string(),
),
..ProjectMetadata::default()
}),
},
],
project_peer_topology: HashMap::new(),
database: "postgresql".to_string(),
is_postgres: true,
is_sqlite: false,
is_mysql: false,
is_mariadb: false,
is_better_sqlite: false,
is_libsql: false,
is_mssql: false,
is_mongo: false,
is_in_memory_database: false,
is_eslint: false,
is_biome: false,
is_oxlint: false,
is_prettier: false,
is_express: false,
is_hyper_express: false,
is_zod: false,
is_typebox: false,
is_bun: false,
is_node: false,
is_vitest: false,
is_jest: false,
platform_application_id: None,
platform_organization_id: None,
framework: None,
compliance: None,
}
}
#[test]
fn worker_producer_and_consumer_have_distinct_native_roles() {
let root = TempDir::new().unwrap();
let mut manifest = rust_release_test_manifest();
let worker = manifest.projects.iter_mut().find(|project| project.r#type == ProjectType::Worker).unwrap();
worker.metadata.as_mut().unwrap().worker_service = Some(true);
let producer = project_native_binary(worker).unwrap();
assert_eq!(project_worker_consumer_binary(worker).unwrap(), format!("{producer}-worker"));
assert!(project_native_worker_role(worker, "control").is_none());
assert!(project_native_worker_role(worker, "consumer").is_none());
crate::core::manifest::language::normalized(root.path(), &manifest).unwrap();
let worker = manifest.projects.iter_mut().find(|project| project.r#type == ProjectType::Worker).unwrap();
worker.metadata.as_mut().unwrap().contract_export = Some(false);
assert!(crate::core::manifest::language::normalized(root.path(), &manifest).is_err());
}
#[test]
fn release_language_normalization_preserves_legacy_rust_and_rejects_conflicts() {
let root = TempDir::new().unwrap();
let mut manifest = rust_release_test_manifest();
manifest.projects[0].metadata.as_mut().unwrap().language = None;
let normalized = crate::core::manifest::language::normalized(root.path(), &manifest).unwrap();
assert!(project_is_rust(&normalized.projects[0]));
assert!(manifest.projects[0].metadata.as_ref().unwrap().language.is_none());
manifest.projects[0].metadata.as_mut().unwrap().language = Some("go".into());
assert!(crate::core::manifest::language::normalized(root.path(), &manifest).is_err());
manifest.projects[0].metadata.as_mut().unwrap().language = Some("rust".into());
manifest.projects[0].metadata.as_mut().unwrap().worker_control_service = Some(true);
assert!(crate::core::manifest::language::normalized(root.path(), &manifest).is_err());
manifest.projects[0].metadata.as_mut().unwrap().worker_control_service = None;
manifest.application_language = Some("typescript".into());
assert!(crate::core::manifest::language::normalized(root.path(), &manifest).is_err());
manifest.language = Some("mixed".into());
manifest.application_language = Some("mixed".into());
let worker = manifest.projects.iter_mut().find(|p| p.r#type == ProjectType::Worker).unwrap();
worker.metadata.as_mut().unwrap().language = Some("typescript".into());
worker.metadata.as_mut().unwrap().worker_control_service = Some(true);
assert!(crate::core::manifest::language::normalized(root.path(), &manifest).is_err());
}
#[test]
fn test_rust_release_manifest_uses_crate_paths_and_root_dockerfile() {
let temp_dir = TempDir::new().unwrap();
let app_root = temp_dir.path();
std::fs::write(app_root.join("Dockerfile"), "FROM rust:1.91-slim\n").unwrap();
std::fs::create_dir_all(app_root.join("crates/accounts")).unwrap();
std::fs::create_dir_all(app_root.join("crates/email-worker")).unwrap();
let manifest = rust_release_test_manifest();
let mut openapi_specs = HashMap::new();
openapi_specs.insert(
"accounts".to_string(),
serde_json::json!({ "openapi": "3.1.0", "info": { "title": "accounts" } }),
);
openapi_specs.insert(
"email-worker".to_string(),
serde_json::json!({ "openapi": "3.1.0", "info": { "title": "email-worker" } }),
);
let release_manifest = generate_release_manifest(
app_root,
"app-id".to_string(),
"1.0.0".to_string(),
"commit".to_string(),
Some("main".to_string()),
None,
&manifest,
&openapi_specs,
Vec::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashSet::new(),
)
.unwrap();
assert_eq!(release_manifest.runtime, Some(Runtime::Tokio));
assert_eq!(
release_manifest.infrastructure.build_context,
Some(".".to_string())
);
let accounts = release_manifest
.services
.iter()
.find(|service| service.id == "accounts")
.expect("accounts service should exist");
assert_eq!(accounts.build_context, Some(".".to_string()));
assert_eq!(accounts.dockerfile, Some("Dockerfile".to_string()));
assert_eq!(accounts.runtime, Some(Runtime::Tokio));
assert_eq!(accounts.binary.as_deref(), Some("accounts"));
let ServiceConfigEnum::Service(accounts_config) = &accounts.config else {
panic!("accounts should be a service config");
};
assert!(accounts_config.open_api_spec.is_some());
let worker = release_manifest
.services
.iter()
.find(|service| service.id == "email-worker-worker")
.expect("email worker should exist");
assert_eq!(worker.build_context, Some(".".to_string()));
assert_eq!(worker.runtime, Some(Runtime::Tokio));
assert_eq!(worker.binary.as_deref(), Some("email-worker"));
assert!(
worker.native_worker_role.is_none(),
"legacy workers retain their original command"
);
let ServiceConfigEnum::Worker(worker_config) = &worker.config else {
panic!("email worker should have worker config");
};
assert_eq!(worker_config.worker_type, WorkerType::PostgreSQL);
}
#[test]
fn mixed_release_respects_compose_context_and_refuses_unsupported_targets() {
let root = TempDir::new().unwrap();
let mut manifest = rust_release_test_manifest();
manifest.dockerfile = None;
let project = &manifest.projects[0];
std::fs::write(
root.path().join("docker-compose.yaml"),
format!(
"services:\n {}:\n build: {{context: ., dockerfile: Dockerfile.typescript}}\n",
project.name
),
)
.unwrap();
assert_eq!(
project_build_context(root.path(), &manifest, project).as_deref(),
Some(".")
);
assert_eq!(
project_dockerfile(root.path(), &manifest, project),
"Dockerfile.typescript"
);
std::fs::write(root.path().join("docker-compose.yaml"),format!("services:\n {}:\n build: {{context: ., dockerfile: Dockerfile.typescript, target: auth}}\n",project.name)).unwrap();
assert!(
check_release_build_targets(root.path(), &manifest)
.unwrap_err()
.to_string()
.contains("target auth")
);
manifest.dockerfile = Some("Custom.Dockerfile".into());
assert!(check_release_build_targets(root.path(), &manifest).is_ok());
assert_eq!(
project_dockerfile(root.path(), &manifest, &manifest.projects[0]),
"Custom.Dockerfile"
);
}
#[test]
fn native_migrations_name_the_binary_and_owner_url_variable() {
let manifest = rust_release_test_manifest();
let (service, worker) = (&manifest.projects[0], &manifest.projects[1]);
let migration = project_native_migration(service, project_native_binary(service)).unwrap();
assert_eq!(migration.binary, "accounts");
assert_eq!(migration.args, ["--migrate"]);
assert_eq!(migration.database_url_env, "MIGRATION_DATABASE_URL");
let consumer = project_native_migration(worker, Some("email-worker-worker".into())).unwrap();
assert_eq!(consumer.database_url_env, "DATABASE_URL");
let mut billing = service.clone();
billing.variant = Some("billing-stripe".into());
assert_eq!(
project_native_migration(&billing, Some("billing".into())).unwrap().database_url_env,
"DATABASE_URL"
);
let mut storeless = service.clone();
storeless.resources = None;
assert!(project_native_migration(&storeless, Some("accounts".into())).is_none());
let mut cac = service.clone();
cac.variant = Some("cac-base".into());
assert!(project_native_migration(&cac, Some("cac".into())).is_none());
}
#[test]
fn release_accepts_owned_shared_process_images() {
let root = TempDir::new().unwrap();
let manifest = rust_release_test_manifest();
std::fs::create_dir_all(root.path().join(".forklaunch")).unwrap();
std::fs::write(root.path().join("Dockerfile.typescript"), "FROM node:22\n").unwrap();
std::fs::write(
root.path().join(".forklaunch/generated.json"),
serde_json::to_string(&serde_json::json!({"typescriptImage": {
"dockerfileSha256": crate::core::forklaunch_state::sha256_hex("FROM node:22\n"), "processes": {"api": {"entry":"server"}}
}}))
.unwrap(),
)
.unwrap();
assert!(check_release_build_targets(root.path(), &manifest).is_ok());
let mut customized = manifest.clone();
customized.dockerfile = Some("Custom.Dockerfile".into());
assert!(check_release_build_targets(root.path(), &customized).is_ok());
std::fs::write(root.path().join("Dockerfile.typescript"), "FROM custom\n").unwrap();
assert!(check_release_build_targets(root.path(), &manifest).is_ok());
}
#[test]
fn mixed_release_uses_each_languages_dockerfile_for_services_and_workers() {
for runtime in ["node", "bun"] {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("Dockerfile.rust"), "FROM rust:1.91-slim\n").unwrap();
std::fs::create_dir_all(root.path().join("modules")).unwrap();
std::fs::write(root.path().join("Dockerfile.typescript"), "FROM node:22\n").unwrap();
let mut manifest = rust_release_test_manifest();
manifest.runtime = runtime.to_string();
manifest.modules_path = "modules".to_string();
manifest.language = Some("mixed".to_string());
manifest.application_language = Some("mixed".to_string());
for project in &mut manifest.projects {
if project.r#type == ProjectType::Worker {
project.metadata.as_mut().unwrap().worker_control_service = Some(true);
}
}
manifest.projects[0].metadata.as_mut().unwrap().crate_name =
Some("accounts-native".into());
let mut legacy = manifest.projects[0].clone();
legacy.name = "legacy-api".to_string();
legacy.metadata = Some(ProjectMetadata {
language: Some("typescript".to_string()),
..ProjectMetadata::default()
});
manifest.projects.push(legacy);
let release = generate_release_manifest(
root.path(),
"app-id".into(),
"1.0.0".into(),
"commit".into(),
None,
None,
&manifest,
&HashMap::new(),
Vec::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashSet::new(),
)
.unwrap();
assert_eq!(release.services.len(), 4);
for service in release.services {
let legacy = service.id == "legacy-api";
let serialized = serde_json::to_value(&service).unwrap();
match service.id.as_str() {
"email-worker-service" => assert_eq!(serialized["nativeWorkerRole"], "--control-service"),
_ => assert!(serialized.get("nativeWorkerRole").is_none()),
}
if legacy {
assert!(serialized.get("runtime").is_none());
assert!(serialized.get("binary").is_none());
} else {
assert_eq!(serialized["runtime"], "tokio");
assert_eq!(
serialized["binary"],
if service.id == "accounts" {
"accounts-native"
} else {
"email-worker"
}
);
}
assert_eq!(
service.build_context.as_deref(),
Some(if legacy { "modules" } else { "." })
);
assert_eq!(
service.dockerfile.as_deref(),
Some(if legacy {
"../Dockerfile.typescript"
} else {
"Dockerfile.rust"
})
);
}
let custom = root.path().join("crates/accounts");
std::fs::create_dir_all(&custom).unwrap();
std::fs::write(custom.join("Dockerfile"), "FROM custom\n").unwrap();
assert_eq!(
project_build_context(root.path(), &manifest, &manifest.projects[0]).as_deref(),
Some("crates/accounts")
);
assert_eq!(
project_dockerfile(root.path(), &manifest, &manifest.projects[0]),
"Dockerfile"
);
manifest.dockerfile = Some("Dockerfile.custom".into());
assert_eq!(
project_dockerfile(root.path(), &manifest, &manifest.projects[2]),
"Dockerfile.custom"
);
}
}
#[test]
fn prebuilt_release_keeps_storefront_paths_after_mixed_migration() {
let root = TempDir::new().unwrap();
let mut manifest = rust_release_test_manifest();
manifest.modules_path = "modules".into();
manifest.runtime = "node".into();
for directory in [
"modules",
"crates/accounts",
"crates/email-worker",
"crates/shop",
] {
std::fs::create_dir_all(root.path().join(directory)).unwrap();
}
let mut capture = manifest.projects[0].clone();
capture.name = "shop".into();
capture.r#type = ProjectType::Library;
capture.variant = Some("storefront".into());
capture.metadata = Some(ProjectMetadata {
static_path: Some("crates/shop".into()),
..ProjectMetadata::default()
});
assert_eq!(project_relative_path(&manifest, &capture), "crates/shop");
manifest.projects.push(capture);
super::super::prebuilt::validate_paths(root.path(), &manifest).unwrap();
manifest
.projects
.last_mut()
.unwrap()
.metadata
.as_mut()
.unwrap()
.static_path = Some("../outside".into());
assert!(super::super::prebuilt::validate_paths(root.path(), &manifest).is_err());
}
#[test]
fn test_required_env_var_serializes_declared_optionality() {
let requirement = EnvironmentVariableRequirement {
name: "OTEL_LEVEL".to_string(),
scope: EnvironmentVariableScope::Service,
scope_id: Some("billing".to_string()),
component: None,
origin: None,
inter_service_url: None,
optional: Some(true),
used_by: Vec::new(),
};
let json = serde_json::to_value(&requirement).unwrap();
assert_eq!(json["optional"], serde_json::json!(true));
let required = EnvironmentVariableRequirement {
optional: Some(false),
..requirement
};
assert_eq!(
serde_json::to_value(&required).unwrap()["optional"],
serde_json::json!(false)
);
}
#[test]
fn test_required_env_var_omits_unknown_optionality() {
let requirement = EnvironmentVariableRequirement {
name: "PLATFORM_INJECTED".to_string(),
scope: EnvironmentVariableScope::Application,
scope_id: None,
component: None,
origin: Some("platform".to_string()),
inter_service_url: None,
optional: None,
used_by: Vec::new(),
};
let json = serde_json::to_value(&requirement).unwrap();
assert!(
json.get("optional").is_none(),
"unknown optionality must not serialize, got: {json}"
);
}
#[test]
fn test_required_env_var_serializes_used_by() {
let requirement = EnvironmentVariableRequirement {
name: "IAM_URL".to_string(),
scope: EnvironmentVariableScope::Application,
scope_id: None,
component: None,
origin: Some("platform".to_string()),
inter_service_url: Some(InterServiceUrlInfo {
target_service: "iam".to_string(),
transport: "http".to_string(),
port_env_var: "PORT".to_string(),
}),
optional: None,
used_by: vec!["billing".to_string(), "insights".to_string()],
};
let json = serde_json::to_value(&requirement).unwrap();
assert_eq!(json["usedBy"], serde_json::json!(["billing", "insights"]));
let unused = EnvironmentVariableRequirement {
used_by: Vec::new(),
..requirement
};
let json = serde_json::to_value(&unused).unwrap();
assert!(
json.get("usedBy").is_none(),
"an empty caller list must not serialize, got: {json}"
);
}
#[test]
fn test_route_definition_has_topology_with_versions() {
use crate::core::library_scanner::Dependency;
let route = RouteDefinition {
id: "get-users".to_string(),
method: "GET".to_string(),
path: "/users".to_string(),
handler: "getUsers".to_string(),
middleware: None,
schema: None,
auth: None,
topology: Some(Topology {
deps: vec![
Dependency {
name: "@forklaunch/core".to_string(),
dep_type: "npm".to_string(),
version: Some("^0.6.5".to_string()),
target_service: None,
source_files: vec![
"api/controllers/user.controller.ts".to_string(),
"domain/services/user.service.ts".to_string(),
],
},
Dependency {
name: "stripe".to_string(),
dep_type: "npm".to_string(),
version: Some("^17.7.0".to_string()),
target_service: None,
source_files: vec!["domain/services/user.service.ts".to_string()],
},
Dependency {
name: "@mikro-orm/core".to_string(),
dep_type: "npm".to_string(),
version: Some("^6.5.0".to_string()),
target_service: None,
source_files: vec!["domain/services/user.service.ts".to_string()],
},
],
}),
};
let json = serde_json::to_string_pretty(&route);
assert!(json.is_ok(), "Route with topology should serialize");
let json_str = json.unwrap();
assert!(
json_str.contains("\"topology\""),
"JSON should contain topology field"
);
assert!(json_str.contains("\"deps\""), "JSON should have deps array");
assert!(
json_str.contains("\"sourceFiles\""),
"JSON should have per-dep sourceFiles"
);
assert!(
json_str.contains("\"@forklaunch/core\""),
"Should contain npm dependency"
);
assert!(
json_str.contains("\"^0.6.5\""),
"Should contain version for @forklaunch/core"
);
assert!(
json_str.contains("\"stripe\""),
"Should contain stripe dependency"
);
assert!(
json_str.contains("\"^17.7.0\""),
"Should contain version for stripe"
);
assert!(
json_str.contains("\"@mikro-orm/core\""),
"Should contain nested npm dep"
);
assert!(
json_str.contains("\"domain/services/user.service.ts\""),
"Should have local path"
);
assert!(
json_str.contains("\"api/controllers/user.controller.ts\""),
"Should have controller path"
);
}
#[test]
fn test_controller_definition_no_topology() {
let controller = ControllerDefinition {
id: "test".to_string(),
name: "test".to_string(),
path: "/test".to_string(),
routes: vec![RouteDefinition {
id: "get-test".to_string(),
method: "GET".to_string(),
path: "/".to_string(),
handler: "getTest".to_string(),
middleware: None,
schema: None,
auth: None,
topology: Some(Topology { deps: vec![] }),
}],
};
let json = serde_json::to_string_pretty(&controller);
assert!(json.is_ok(), "Controller should serialize");
let json_str = json.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let obj = parsed.as_object().unwrap();
assert!(
!obj.contains_key("topology"),
"Controller should not have top-level topology"
);
assert!(obj.contains_key("routes"), "Controller should have routes");
let routes = obj.get("routes").unwrap().as_array().unwrap();
let first_route = routes[0].as_object().unwrap();
assert!(
first_route.contains_key("topology"),
"Route should have topology"
);
}
#[test]
fn test_service_config_hosting_type_ecs_ec2() {
let config = ServiceConfig {
service_type: ConfigType::Service,
controllers: None,
integrations: None,
open_api_spec: None,
dependencies: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: Some("ecs-ec2".to_string()),
health_check: None,
is_worker_service: None,
privileged: None,
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["hostingType"], "ecs-ec2");
}
#[test]
fn test_service_config_hosting_type_defaults_absent() {
let config = ServiceConfig {
service_type: ConfigType::Service,
controllers: None,
integrations: None,
open_api_spec: None,
dependencies: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: None,
health_check: None,
is_worker_service: None,
privileged: None,
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(
json.get("hostingType").is_none(),
"hostingType should be omitted when None"
);
}
#[test]
fn test_worker_config_hosting_type_ecs_ec2() {
let config = WorkerConfig {
config_type: ConfigType::Worker,
worker_type: WorkerType::BullMQ,
concurrency: None,
timeout: None,
max_retries: None,
queue: None,
priority: None,
dead_letter_queue: None,
additional: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: Some("ecs-ec2".to_string()),
health_check: None,
privileged: None,
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["hostingType"], "ecs-ec2");
}
#[test]
fn test_worker_config_privileged_round_trips() {
let config = WorkerConfig {
config_type: ConfigType::Worker,
worker_type: WorkerType::BullMQ,
concurrency: None,
timeout: None,
max_retries: None,
queue: None,
priority: None,
dead_letter_queue: None,
additional: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: Some("ecs-ec2".to_string()),
health_check: None,
privileged: Some(true),
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["privileged"], true);
}
#[test]
fn test_worker_config_privileged_omitted_when_absent() {
let config = WorkerConfig {
config_type: ConfigType::Worker,
worker_type: WorkerType::BullMQ,
concurrency: None,
timeout: None,
max_retries: None,
queue: None,
priority: None,
dead_letter_queue: None,
additional: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: None,
health_check: None,
privileged: None,
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(
json.get("privileged").is_none(),
"privileged should be omitted when None"
);
}
#[test]
fn test_worker_config_hosting_type_defaults_absent() {
let config = WorkerConfig {
config_type: ConfigType::Worker,
worker_type: WorkerType::BullMQ,
concurrency: None,
timeout: None,
max_retries: None,
queue: None,
priority: None,
dead_letter_queue: None,
additional: None,
runtime_dependencies: None,
instance_size: None,
hosting_type: None,
health_check: None,
privileged: None,
serves: None,
};
let json = serde_json::to_value(&config).unwrap();
assert!(
json.get("hostingType").is_none(),
"hostingType should be omitted when None"
);
}
#[test]
fn test_serving_ports_reach_the_release_manifest_in_camel_case() {
let project: crate::core::manifest::ProjectEntry = toml::from_str(
r#"
type = "Service"
name = "platform-management"
description = "control plane"
[[serves]]
protocol = "ws"
port_env = "WS_PORT"
health_path = "/health"
"#,
)
.unwrap();
let serves = serving_ports(&project).expect("declared ports are forwarded");
let json = serde_json::to_value(&serves).unwrap();
assert_eq!(
json,
serde_json::json!([{
"protocol": "ws",
"portEnv": "WS_PORT",
"healthPath": "/health"
}])
);
let rewritten = toml::to_string(&project).unwrap();
assert!(rewritten.contains("port_env = \"WS_PORT\""), "{rewritten}");
let bare: crate::core::manifest::ProjectEntry =
toml::from_str("type = \"Service\"\nname = \"iam\"\ndescription = \"\"\n").unwrap();
assert!(serving_ports(&bare).is_none());
assert!(!toml::to_string(&bare).unwrap().contains("serves"));
}
}