use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::LinearConfig;
use crate::utils::{
collapse_project_path, config_kind_from_path, find_xbp_config_upwards,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
resolve_env_placeholders, resolve_project_dir, resolve_project_path, serialize_xbp_yaml,
};
use xbp_core::RunnerExecutionRuntime;
fn default_xbp_version() -> String {
"0.1.0".to_string()
}
fn default_auto_push_on_commit() -> bool {
true
}
pub const DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE: &str = "releases/{VERSION}-{SERVICE_NAME}";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishTargetConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub package_name: Option<String>,
#[serde(default)]
pub working_directory: Option<String>,
#[serde(default)]
pub manifest_path: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub preflight_commands: Vec<String>,
#[serde(default)]
pub publish_command: Option<String>,
#[serde(default)]
pub use_wsl: Option<bool>,
#[serde(default)]
pub wsl_distribution: Option<String>,
#[serde(default)]
pub generate_npmrc: Option<bool>,
#[serde(default)]
pub access: Option<String>,
#[serde(default)]
pub retry_no_verify: Option<bool>,
#[serde(default)]
pub no_verify_retry_packages: Vec<String>,
#[serde(default)]
pub visibility_timeout_seconds: Option<f64>,
#[serde(default)]
pub visibility_poll_seconds: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishProjectConfig {
#[serde(default)]
pub npm: Option<PublishTargetConfig>,
#[serde(default)]
pub crates: Option<PublishTargetConfig>,
#[serde(default)]
pub dist: Option<PublishDistConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PublishDistConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub packages: Vec<String>,
#[serde(default)]
pub installers: Vec<String>,
#[serde(default)]
pub targets: Vec<String>,
#[serde(default, alias = "artifacts")]
pub artifacts_modes: Vec<String>,
#[serde(default)]
pub allow_dirty: Option<bool>,
#[serde(default)]
pub generate_ci: Option<bool>,
#[serde(default)]
pub tool: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceCommands {
#[serde(default)]
pub pre: Option<String>,
#[serde(default)]
pub install: Option<String>,
#[serde(default)]
pub build: Option<String>,
#[serde(default)]
pub start: Option<String>,
#[serde(default)]
pub dev: Option<String>,
#[serde(default, flatten)]
pub custom: BTreeMap<String, String>,
}
impl ServiceCommands {
pub fn get(&self, name: &str) -> Option<&String> {
match name {
"pre" => self.pre.as_ref(),
"install" => self.install.as_ref(),
"build" => self.build.as_ref(),
"start" => self.start.as_ref(),
"dev" => self.dev.as_ref(),
other => self.custom.get(other),
}
}
pub fn available_names(&self) -> Vec<String> {
let mut names = ["pre", "install", "build", "start", "dev"]
.into_iter()
.filter(|name| self.get(name).is_some_and(|value| !value.trim().is_empty()))
.map(str::to_string)
.collect::<Vec<_>>();
names.extend(
self.custom
.iter()
.filter(|(_, value)| !value.trim().is_empty())
.map(|(name, _)| name.clone()),
);
names
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SystemdConfig {
#[serde(default)]
pub environment_files: Vec<String>,
#[serde(default)]
pub config_paths: Vec<String>,
#[serde(default)]
pub read_write_paths: Vec<String>,
#[serde(default)]
pub runtime_directories: Vec<String>,
#[serde(default)]
pub state_directories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerDeployConfig {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub ci_script: Option<String>,
#[serde(default)]
pub select_script: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerContainerConfig {
#[serde(default)]
pub class_name: Option<String>,
#[serde(default)]
pub binding: Option<String>,
#[serde(default)]
pub dockerfile: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub application_id: Option<String>,
#[serde(default)]
pub healthcheck_path: Option<String>,
#[serde(default)]
pub health_urls: Vec<String>,
#[serde(default)]
pub expected_image_name: Option<String>,
#[serde(default)]
pub image_tag_keep_count: Option<usize>,
#[serde(default)]
pub allow_unchanged_container_image: bool,
#[serde(default)]
pub required_secrets: Vec<String>,
#[serde(default)]
pub version_var: Option<String>,
#[serde(default)]
pub instance_name_template: Option<String>,
#[serde(default)]
pub default_rollout: Option<String>,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub build_context: Option<String>,
#[serde(default)]
pub instance_type: Option<String>,
#[serde(default)]
pub max_instances: Option<u32>,
#[serde(default)]
pub rollout_active_grace_period: Option<u32>,
#[serde(default)]
pub rollout_step_percentage: Vec<u8>,
#[serde(default)]
pub regions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerDurableObjectBinding {
pub name: String,
pub class_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerDurableObjectMigration {
pub tag: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub new_classes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub new_sqlite_classes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerDurableObjectNamespace {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(rename = "class", alias = "class_name")]
pub class_name: String,
#[serde(default)]
pub use_sqlite: bool,
#[serde(default)]
pub use_containers: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkerDurableObjectsConfig {
#[serde(default)]
pub bindings: Vec<WorkerDurableObjectBinding>,
#[serde(default)]
pub migrations: Vec<WorkerDurableObjectMigration>,
#[serde(default)]
pub namespaces: Vec<WorkerDurableObjectNamespace>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig {
pub name: String,
pub root: String,
#[serde(default)]
pub script_name: Option<String>,
#[serde(default)]
pub service: Option<String>,
#[serde(default)]
pub deploy: Option<WorkerDeployConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<WorkerContainerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub containers: Option<Vec<WorkerContainerConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub durable_objects: Option<WorkerDurableObjectsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VersionDomainSurfaceConfig {
pub kind: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marker: Option<String>,
#[serde(default)]
pub packages: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VersionDomainConfig {
pub name: String,
pub root: String,
pub version: String,
#[serde(default)]
pub owned_package_names: Vec<String>,
#[serde(default)]
pub owned_package_prefixes: Vec<String>,
#[serde(default)]
pub version_surfaces: Vec<VersionDomainSurfaceConfig>,
#[serde(default)]
pub allowed_bumps: Vec<String>,
#[serde(default)]
pub validation_commands: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_version_probe: Option<String>,
#[serde(default)]
pub live_health_urls: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloudflare_app: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub url_env: Option<String>,
#[serde(default)]
pub key_env: Option<String>,
#[serde(default)]
pub schema: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiOutputsConfig {
#[serde(default)]
pub yaml: Option<String>,
#[serde(default)]
pub json: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiServiceDefaultsConfig {
#[serde(default)]
pub spec_version: Option<String>,
#[serde(default)]
pub strict: Option<bool>,
#[serde(default)]
pub source_roots: Option<Vec<String>>,
#[serde(default)]
pub outputs: Option<OpenApiOutputsConfig>,
#[serde(default)]
pub unknown_schema: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiAggregateConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub outputs: Option<OpenApiOutputsConfig>,
#[serde(default)]
pub overlay: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ProjectOpenApiConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub auto_detect_services: Option<bool>,
#[serde(default)]
pub generate_on_release: Option<bool>,
#[serde(default)]
pub service_defaults: Option<OpenApiServiceDefaultsConfig>,
#[serde(default)]
pub aggregate: Option<OpenApiAggregateConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiLocalServerConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiServerDerivationConfig {
#[serde(default)]
pub from_service_url: Option<bool>,
#[serde(default)]
pub service_url_description: Option<String>,
#[serde(default)]
pub local: Option<OpenApiLocalServerConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpenApiWebSocketRouteConfig {
pub path: String,
#[serde(default)]
pub client_message_schema: Option<String>,
#[serde(default)]
pub server_message_schema: Option<String>,
#[serde(default)]
pub message_format: Option<String>,
#[serde(default)]
pub server_sends_first: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ServiceOpenApiConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub framework: Option<String>,
#[serde(default)]
pub spec_version: Option<String>,
#[serde(default)]
pub strict: Option<bool>,
#[serde(default)]
pub source_roots: Option<Vec<String>>,
#[serde(default)]
pub outputs: Option<OpenApiOutputsConfig>,
#[serde(default)]
pub unknown_schema: Option<String>,
#[serde(default)]
pub overlay: Option<String>,
#[serde(default)]
pub aggregate_path_prefix: Option<String>,
#[serde(default)]
pub server_derivation: Option<OpenApiServerDerivationConfig>,
#[serde(default)]
pub websocket_routes: Option<Vec<OpenApiWebSocketRouteConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OciProjectConfig {
#[serde(default)]
pub registries: HashMap<String, OciRegistryConfig>,
#[serde(default)]
pub default_registry: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OciRegistryConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub token_source: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceOciConfig {
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub tag: Option<String>,
#[serde(default)]
pub tag_from: Option<String>,
#[serde(default)]
pub dockerfile: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub build_args: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KubernetesProjectConfig {
#[serde(default)]
pub default_context: Option<String>,
#[serde(default)]
pub default_namespace: Option<String>,
#[serde(default)]
pub require_context_confirmation_for_apply: Option<bool>,
#[serde(default)]
pub kubeconfig: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeployProjectConfig {
#[serde(default)]
pub default_env: Option<String>,
#[serde(default)]
pub history_dir: Option<String>,
#[serde(default)]
pub lock_file: Option<String>,
#[serde(default)]
pub groups: HashMap<String, DeployGroupConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeployGroupConfig {
#[serde(default)]
pub services: Vec<String>,
#[serde(default)]
pub order: Vec<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceDeployConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub envs: HashMap<String, ServiceDeployEnvConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceDeployEnvConfig {
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub replicas: Option<u32>,
#[serde(default)]
pub health: Vec<String>,
#[serde(default)]
pub kubernetes: Option<ServiceDeployKubernetesConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceDeployKubernetesConfig {
#[serde(default)]
pub manifests: Option<ServiceDeployManifestsConfig>,
#[serde(default)]
pub workload: Option<String>,
#[serde(default)]
pub service: Option<String>,
#[serde(default)]
pub crds_path: Option<String>,
#[serde(default)]
pub install_path: Option<String>,
#[serde(default)]
pub selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceDeployManifestsConfig {
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub overlay: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceConfig {
pub name: String,
pub target: String,
pub branch: String,
pub port: u16,
#[serde(default)]
pub root_directory: Option<String>,
#[serde(default)]
pub environment: Option<HashMap<String, String>>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub healthcheck_path: Option<String>,
#[serde(default)]
pub restart_policy: Option<String>,
#[serde(default)]
pub restart_policy_max_failure_count: Option<u32>,
#[serde(default)]
pub start_wrapper: Option<String>,
#[serde(default)]
pub commands: Option<ServiceCommands>,
#[serde(default)]
pub force_run_from_root: Option<bool>,
#[serde(default)]
pub version_targets: Option<Vec<String>>,
#[serde(default)]
pub watch_paths: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "versioning_enabled",
alias = "enable_versioning"
)]
pub versioning: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "release_enabled",
alias = "enable_release"
)]
pub release: Option<bool>,
#[serde(default)]
pub systemd_service_name: Option<String>,
#[serde(default)]
pub systemd: Option<SystemdConfig>,
#[serde(default)]
pub openapi: Option<ServiceOpenApiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oci: Option<ServiceOciConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy: Option<ServiceDeployConfig>,
}
impl ServiceConfig {
pub fn is_versioning_enabled(&self) -> bool {
self.versioning.unwrap_or(true)
}
pub fn is_release_enabled(&self) -> bool {
self.is_versioning_enabled() && self.release.unwrap_or(true)
}
}
fn resolve_systemd_fields(
systemd_service_name: Option<String>,
systemd: Option<JsonValue>,
) -> Result<(Option<String>, Option<SystemdConfig>), String> {
match systemd {
None | Some(JsonValue::Null) => Ok((systemd_service_name, None)),
Some(JsonValue::String(name)) => {
let resolved_name = if systemd_service_name
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
{
systemd_service_name
} else if name.is_empty() {
None
} else {
Some(name)
};
Ok((resolved_name, None))
}
Some(value) => {
let config: SystemdConfig = serde_json::from_value(value)
.map_err(|error| format!("invalid systemd config: {error}"))?;
Ok((systemd_service_name, Some(config)))
}
}
}
fn deserialize_service_config<'de, D>(deserializer: D) -> Result<ServiceConfig, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct ServiceConfigRaw {
name: String,
target: String,
branch: String,
port: u16,
#[serde(default)]
root_directory: Option<String>,
#[serde(default)]
environment: Option<HashMap<String, String>>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
healthcheck_path: Option<String>,
#[serde(default)]
restart_policy: Option<String>,
#[serde(default)]
restart_policy_max_failure_count: Option<u32>,
#[serde(default)]
start_wrapper: Option<String>,
#[serde(default)]
commands: Option<ServiceCommands>,
#[serde(default)]
force_run_from_root: Option<bool>,
#[serde(default)]
version_targets: Option<Vec<String>>,
#[serde(default)]
watch_paths: Option<Vec<String>>,
#[serde(default, alias = "versioning_enabled", alias = "enable_versioning")]
versioning: Option<bool>,
#[serde(default, alias = "release_enabled", alias = "enable_release")]
release: Option<bool>,
#[serde(default)]
systemd_service_name: Option<String>,
#[serde(default)]
systemd: Option<JsonValue>,
#[serde(default)]
openapi: Option<ServiceOpenApiConfig>,
#[serde(default)]
oci: Option<ServiceOciConfig>,
#[serde(default)]
deploy: Option<ServiceDeployConfig>,
}
let raw = ServiceConfigRaw::deserialize(deserializer)?;
let (systemd_service_name, systemd) =
resolve_systemd_fields(raw.systemd_service_name, raw.systemd)
.map_err(serde::de::Error::custom)?;
Ok(ServiceConfig {
name: raw.name,
target: raw.target,
branch: raw.branch,
port: raw.port,
root_directory: raw.root_directory,
environment: raw.environment,
url: raw.url,
healthcheck_path: raw.healthcheck_path,
restart_policy: raw.restart_policy,
restart_policy_max_failure_count: raw.restart_policy_max_failure_count,
start_wrapper: raw.start_wrapper,
commands: raw.commands,
force_run_from_root: raw.force_run_from_root,
version_targets: raw.version_targets,
watch_paths: raw.watch_paths,
versioning: raw.versioning,
release: raw.release,
systemd_service_name,
systemd,
openapi: raw.openapi,
oci: raw.oci,
deploy: raw.deploy,
})
}
impl<'de> Deserialize<'de> for ServiceConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserialize_service_config(deserializer)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct XbpConfig {
pub project_name: String,
#[serde(default = "default_xbp_version")]
pub version: String,
pub port: u16,
pub build_dir: String,
#[serde(default)]
pub app_type: Option<String>,
#[serde(default)]
pub build_command: Option<String>,
#[serde(default)]
pub start_command: Option<String>,
#[serde(default)]
pub install_command: Option<String>,
#[serde(default)]
pub environment: Option<HashMap<String, String>>,
#[serde(default)]
pub services: Option<Vec<ServiceConfig>>,
#[serde(default)]
pub openapi: Option<ProjectOpenApiConfig>,
#[serde(default)]
pub workers: Option<Vec<WorkerConfig>>,
#[serde(default)]
pub systemd_service_name: Option<String>,
#[serde(default)]
pub systemd: Option<SystemdConfig>,
#[serde(default)]
pub kafka_brokers: Option<String>,
#[serde(default)]
pub kafka_topic: Option<String>,
#[serde(default)]
pub kafka_public_url: Option<String>,
#[serde(default)]
pub log_files: Option<Vec<String>>,
#[serde(default)]
pub monitor_url: Option<String>,
#[serde(default)]
pub monitor_method: Option<String>,
#[serde(default)]
pub monitor_expected_code: Option<u16>,
#[serde(default)]
pub monitor_interval: Option<u64>,
#[serde(default)]
pub database: Option<DatabaseConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oci: Option<OciProjectConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kubernetes: Option<KubernetesProjectConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy: Option<DeployProjectConfig>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub crate_name: Option<String>,
#[serde(default)]
pub npm_script: Option<String>,
#[serde(default)]
pub port_storybook: Option<u16>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub url_storybook: Option<String>,
#[serde(default)]
pub linear: Option<LinearConfig>,
#[serde(default)]
pub github: Option<GitHubProjectConfig>,
#[serde(default)]
pub publish: Option<PublishProjectConfig>,
#[serde(default)]
pub version_targets: Vec<String>,
#[serde(default)]
pub version_domains: Vec<VersionDomainConfig>,
#[serde(default, alias = "ignored_paths", alias = "xbpignore")]
pub ignore_paths: Vec<String>,
#[serde(default, alias = "watch_ignore")]
pub watch_ignore_paths: Vec<String>,
#[serde(
default,
alias = "disabled_versioning_services",
alias = "no_version_services",
alias = "versioning_disabled_services"
)]
pub versioning_disabled: Vec<String>,
#[serde(
default,
alias = "disabled_release_services",
alias = "no_release_services",
alias = "release_disabled_services"
)]
pub release_disabled: Vec<String>,
}
fn deserialize_xbp_config<'de, D>(deserializer: D) -> Result<XbpConfig, D::Error>
where
D: Deserializer<'de>,
{
let mut value = JsonValue::deserialize(deserializer)?;
let _ = crate::utils::normalize_xbp_config_value(&mut value);
XbpConfigBody::deserialize(value)
.map(Into::into)
.map_err(serde::de::Error::custom)
}
#[derive(Deserialize)]
struct XbpConfigBody {
project_name: String,
#[serde(default = "default_xbp_version")]
version: String,
port: u16,
build_dir: String,
#[serde(default)]
app_type: Option<String>,
#[serde(default)]
build_command: Option<String>,
#[serde(default)]
start_command: Option<String>,
#[serde(default)]
install_command: Option<String>,
#[serde(default)]
environment: Option<HashMap<String, String>>,
#[serde(default)]
services: Option<Vec<ServiceConfig>>,
#[serde(default)]
openapi: Option<ProjectOpenApiConfig>,
#[serde(default)]
workers: Option<Vec<WorkerConfig>>,
#[serde(default)]
systemd_service_name: Option<String>,
#[serde(default)]
systemd: Option<SystemdConfig>,
#[serde(default)]
kafka_brokers: Option<String>,
#[serde(default)]
kafka_topic: Option<String>,
#[serde(default)]
kafka_public_url: Option<String>,
#[serde(default)]
log_files: Option<Vec<String>>,
#[serde(default)]
monitor_url: Option<String>,
#[serde(default)]
monitor_method: Option<String>,
#[serde(default)]
monitor_expected_code: Option<u16>,
#[serde(default)]
monitor_interval: Option<u64>,
#[serde(default)]
database: Option<DatabaseConfig>,
#[serde(default)]
oci: Option<OciProjectConfig>,
#[serde(default)]
kubernetes: Option<KubernetesProjectConfig>,
#[serde(default)]
deploy: Option<DeployProjectConfig>,
#[serde(default)]
target: Option<String>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
crate_name: Option<String>,
#[serde(default)]
npm_script: Option<String>,
#[serde(default)]
port_storybook: Option<u16>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
url_storybook: Option<String>,
#[serde(default)]
linear: Option<LinearConfig>,
#[serde(default)]
github: Option<GitHubProjectConfig>,
#[serde(default)]
publish: Option<PublishProjectConfig>,
#[serde(default)]
version_targets: Vec<String>,
#[serde(default)]
version_domains: Vec<VersionDomainConfig>,
#[serde(default, alias = "ignored_paths", alias = "xbpignore")]
ignore_paths: Vec<String>,
#[serde(default, alias = "watch_ignore")]
watch_ignore_paths: Vec<String>,
#[serde(
default,
alias = "disabled_versioning_services",
alias = "no_version_services",
alias = "versioning_disabled_services"
)]
versioning_disabled: Vec<String>,
#[serde(
default,
alias = "disabled_release_services",
alias = "no_release_services",
alias = "release_disabled_services"
)]
release_disabled: Vec<String>,
}
impl From<XbpConfigBody> for XbpConfig {
fn from(body: XbpConfigBody) -> Self {
Self {
project_name: body.project_name,
version: body.version,
port: body.port,
build_dir: body.build_dir,
app_type: body.app_type,
build_command: body.build_command,
start_command: body.start_command,
install_command: body.install_command,
environment: body.environment,
services: body.services,
openapi: body.openapi,
workers: body.workers,
systemd_service_name: body.systemd_service_name,
systemd: body.systemd,
kafka_brokers: body.kafka_brokers,
kafka_topic: body.kafka_topic,
kafka_public_url: body.kafka_public_url,
log_files: body.log_files,
monitor_url: body.monitor_url,
monitor_method: body.monitor_method,
monitor_expected_code: body.monitor_expected_code,
monitor_interval: body.monitor_interval,
database: body.database,
oci: body.oci,
kubernetes: body.kubernetes,
deploy: body.deploy,
target: body.target,
branch: body.branch,
crate_name: body.crate_name,
npm_script: body.npm_script,
port_storybook: body.port_storybook,
url: body.url,
url_storybook: body.url_storybook,
linear: body.linear,
github: body.github,
publish: body.publish,
version_targets: body.version_targets,
version_domains: body.version_domains,
ignore_paths: body.ignore_paths,
watch_ignore_paths: body.watch_ignore_paths,
versioning_disabled: body.versioning_disabled,
release_disabled: body.release_disabled,
}
}
}
pub fn service_matches_disable_pattern(service: &ServiceConfig, pattern: &str) -> bool {
let pattern = pattern.trim().replace('\\', "/");
if pattern.is_empty() {
return false;
}
let name = service.name.trim();
if glob_simple_match(&pattern, name) {
return true;
}
if let Some(root) = service.root_directory.as_deref() {
let normalized = root
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string();
if !normalized.is_empty() && glob_simple_match(&pattern, &normalized) {
return true;
}
}
false
}
pub fn service_matches_any_disable_pattern(service: &ServiceConfig, patterns: &[String]) -> bool {
patterns
.iter()
.any(|pattern| service_matches_disable_pattern(service, pattern))
}
pub fn service_versioning_allowed(config: &XbpConfig, service: &ServiceConfig) -> bool {
service.is_versioning_enabled()
&& !service_matches_any_disable_pattern(service, &config.versioning_disabled)
}
pub fn service_release_allowed(config: &XbpConfig, service: &ServiceConfig) -> bool {
service.is_release_enabled()
&& service_versioning_allowed(config, service)
&& !service_matches_any_disable_pattern(service, &config.release_disabled)
}
fn glob_simple_match(pattern: &str, value: &str) -> bool {
let pattern = pattern.trim_matches('/');
let value = value.trim_matches('/');
if pattern == value {
return true;
}
if pattern == "*" || pattern == "**" {
return true;
}
if !pattern.contains('*') && !pattern.contains('/') {
return pattern.eq_ignore_ascii_case(value)
|| value
.split('/')
.any(|segment| segment.eq_ignore_ascii_case(pattern));
}
let pattern_parts: Vec<&str> = pattern.split('/').filter(|p| !p.is_empty()).collect();
let value_parts: Vec<&str> = value.split('/').filter(|p| !p.is_empty()).collect();
glob_parts_match(&pattern_parts, &value_parts)
}
fn glob_parts_match(pattern: &[&str], value: &[&str]) -> bool {
match (pattern.first().copied(), value.first().copied()) {
(None, None) => true,
(Some("**"), _) => {
if glob_parts_match(&pattern[1..], value) {
return true;
}
if value.is_empty() {
return false;
}
glob_parts_match(pattern, &value[1..])
}
(Some(pat), Some(seg)) => {
if segment_star_match(pat, seg) {
glob_parts_match(&pattern[1..], &value[1..])
} else {
false
}
}
_ => false,
}
}
fn segment_star_match(pattern: &str, segment: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern.eq_ignore_ascii_case(segment);
}
let parts: Vec<&str> = pattern.split('*').collect();
let lower = segment.to_ascii_lowercase();
let mut rest = lower.as_str();
for (index, part) in parts.iter().enumerate() {
let part_l = part.to_ascii_lowercase();
if part_l.is_empty() {
continue;
}
if index == 0 {
if !rest.starts_with(&part_l) {
return false;
}
rest = &rest[part_l.len()..];
continue;
}
if index == parts.len() - 1 {
return rest.ends_with(&part_l);
}
if let Some(pos) = rest.find(&part_l) {
rest = &rest[pos + part_l.len()..];
} else {
return false;
}
}
true
}
impl<'de> Deserialize<'de> for XbpConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserialize_xbp_config(deserializer)
}
}
#[derive(Debug, Clone)]
pub struct GitHubProjectConfig {
pub repository: Option<String>,
pub auto_push_on_commit: bool,
pub release_branch: Option<GitHubReleaseBranchConfig>,
pub runners: Option<GitHubRunnersProjectConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GitHubProjectConfigObject {
#[serde(default, skip_serializing_if = "Option::is_none")]
repository: Option<String>,
#[serde(
default = "default_auto_push_on_commit",
skip_serializing_if = "github_auto_push_on_commit_is_default"
)]
auto_push_on_commit: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
release_branch: Option<GitHubReleaseBranchConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
runners: Option<GitHubRunnersProjectConfig>,
}
fn github_auto_push_on_commit_is_default(value: &bool) -> bool {
*value
}
impl Default for GitHubProjectConfig {
fn default() -> Self {
Self {
repository: None,
auto_push_on_commit: default_auto_push_on_commit(),
release_branch: None,
runners: None,
}
}
}
impl Serialize for GitHubProjectConfig {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(repository) = &self.repository {
if self.auto_push_on_commit && self.release_branch.is_none() && self.runners.is_none() {
return serializer.serialize_str(repository);
}
}
GitHubProjectConfigObject {
repository: self.repository.clone(),
auto_push_on_commit: self.auto_push_on_commit,
release_branch: self.release_branch.clone(),
runners: self.runners.clone(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GitHubProjectConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Repository(String),
Config(GitHubProjectConfigObject),
}
match Repr::deserialize(deserializer)? {
Repr::Repository(repository) => Ok(Self {
repository: Some(repository),
..Self::default()
}),
Repr::Config(config) => Ok(Self {
repository: config.repository,
auto_push_on_commit: config.auto_push_on_commit,
release_branch: config.release_branch,
runners: config.runners,
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHubRunnersProjectConfig {
#[serde(default)]
pub default_organization: Option<String>,
#[serde(default)]
pub default_runner_group: Option<String>,
#[serde(default)]
pub visibility: Option<String>,
#[serde(default)]
pub default_labels: Vec<String>,
#[serde(default)]
pub preferred_runtime: Option<RunnerExecutionRuntime>,
#[serde(default)]
pub work_directory: Option<String>,
#[serde(default)]
pub templates: Vec<GitHubRunnerTemplateConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHubRunnerTemplateConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub runner_group: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub runtime: Option<RunnerExecutionRuntime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHubReleaseBranchConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, alias = "template")]
pub naming_template: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubReleaseBranchSettings {
pub naming_template: String,
}
#[derive(Debug, Clone)]
pub struct DeploymentConfig {
pub app_name: String,
pub port: u16,
pub app_dir: PathBuf,
pub build_command: Option<String>,
pub start_command: Option<String>,
pub install_command: Option<String>,
pub environment: HashMap<String, String>,
}
pub fn normalize_config_paths_for_persistence(
config: &mut XbpConfig,
project_root: &std::path::Path,
) {
config.build_dir = collapse_project_path(project_root, &config.build_dir);
if let Some(publish) = &mut config.publish {
for target in [&mut publish.npm, &mut publish.crates]
.into_iter()
.flatten()
{
if let Some(working_directory) = &target.working_directory {
target.working_directory =
Some(collapse_project_path(project_root, working_directory));
}
if let Some(manifest_path) = &target.manifest_path {
target.manifest_path = Some(collapse_project_path(project_root, manifest_path));
}
}
}
for target in &mut config.version_targets {
*target = collapse_project_path(project_root, target);
}
for domain in &mut config.version_domains {
domain.root = collapse_project_path(project_root, &domain.root);
for surface in &mut domain.version_surfaces {
surface.path = collapse_project_path(project_root, &surface.path);
}
}
if let Some(services) = &mut config.services {
for service in services {
if let Some(root_directory) = &service.root_directory {
service.root_directory = Some(collapse_project_path(project_root, root_directory));
}
if let Some(version_targets) = &mut service.version_targets {
for target in version_targets {
*target = collapse_project_path(project_root, target);
}
}
if let Some(watch_paths) = &mut service.watch_paths {
for path in watch_paths {
*path = collapse_project_path(project_root, path);
}
}
}
}
if let Some(workers) = &mut config.workers {
for worker in workers {
let original_worker_root = PathBuf::from(&worker.root);
if let Some(container) = &mut worker.container {
normalize_worker_container_paths(project_root, &original_worker_root, container);
}
worker.root = collapse_project_path(project_root, &worker.root);
}
}
}
pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
config.build_dir = resolve_project_dir(project_root, &config.build_dir);
if let Some(publish) = &mut config.publish {
for target in [&mut publish.npm, &mut publish.crates]
.into_iter()
.flatten()
{
if let Some(working_directory) = &target.working_directory {
target.working_directory =
Some(resolve_project_path(project_root, working_directory));
}
if let Some(manifest_path) = &target.manifest_path {
target.manifest_path = Some(resolve_project_path(project_root, manifest_path));
}
}
}
for target in &mut config.version_targets {
*target = resolve_project_path(project_root, target);
}
for domain in &mut config.version_domains {
domain.root = resolve_project_path(project_root, &domain.root);
for surface in &mut domain.version_surfaces {
surface.path = resolve_project_path(project_root, &surface.path);
}
}
if let Some(services) = &mut config.services {
for service in services {
if let Some(root_directory) = &service.root_directory {
service.root_directory = Some(resolve_project_dir(project_root, root_directory));
}
if let Some(version_targets) = &mut service.version_targets {
for target in version_targets {
*target = resolve_project_path(project_root, target);
}
}
if let Some(watch_paths) = &mut service.watch_paths {
for path in watch_paths {
*path = resolve_project_path(project_root, path);
}
}
}
}
if let Some(workers) = &mut config.workers {
for worker in workers {
worker.root = resolve_project_path(project_root, &worker.root);
if let Some(container) = &mut worker.container {
resolve_worker_container_paths(Path::new(&worker.root), container);
}
}
}
}
fn normalize_worker_container_paths(
project_root: &Path,
worker_root: &Path,
container: &mut WorkerContainerConfig,
) {
let Some(dockerfile) = container.dockerfile.as_ref() else {
return;
};
let dockerfile_path = PathBuf::from(dockerfile);
if !dockerfile_path.is_absolute() {
return;
}
if let Ok(relative) = dockerfile_path.strip_prefix(worker_root) {
let rendered = relative.to_string_lossy().replace('\\', "/");
container.dockerfile = Some(if rendered.is_empty() {
"Dockerfile".to_string()
} else {
rendered
});
return;
}
container.dockerfile = Some(collapse_project_path(project_root, dockerfile));
}
fn resolve_worker_container_paths(worker_root: &Path, container: &mut WorkerContainerConfig) {
let Some(dockerfile) = container.dockerfile.as_ref() else {
return;
};
if PathBuf::from(dockerfile).is_absolute() {
return;
}
container.dockerfile = Some(worker_root.join(dockerfile).to_string_lossy().to_string());
}
impl XbpConfig {
pub fn auto_push_on_commit_enabled(&self) -> bool {
self.github
.as_ref()
.map(|config| config.auto_push_on_commit)
.unwrap_or(true)
}
pub fn github_release_branch_settings(&self) -> Option<GitHubReleaseBranchSettings> {
let release_branch = self
.github
.as_ref()
.and_then(|config| config.release_branch.as_ref())?;
if !release_branch.enabled {
return None;
}
let naming_template = release_branch
.naming_template
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
.to_string();
Some(GitHubReleaseBranchSettings { naming_template })
}
}
impl DeploymentConfig {
pub async fn from_args_or_config(
app_name: Option<String>,
port: Option<u16>,
app_dir: Option<PathBuf>,
config_path: Option<PathBuf>,
) -> Result<Self, String> {
let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
Self::load_xbp_config(config_path).await.ok()
} else {
None
};
let app_name = app_name
.or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
.ok_or("Missing app name")?;
let port = port
.or_else(|| xbp_config.as_ref().map(|c| c.port))
.ok_or("Missing port")?;
let app_dir = app_dir
.or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let app_dir = app_dir
.canonicalize()
.map_err(|e| format!("Failed to resolve app directory: {}", e))?;
let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
let environment = xbp_config
.as_ref()
.and_then(|c| c.environment.clone())
.unwrap_or_default();
let environment = resolve_env_placeholders(&app_dir, &environment);
Ok(DeploymentConfig {
app_name,
port,
app_dir,
build_command,
start_command,
install_command,
environment,
})
}
pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
Self::load_xbp_config_with_options(config_path, true).await
}
pub async fn load_xbp_config_lenient(
config_path: Option<PathBuf>,
) -> Result<XbpConfig, String> {
Self::load_xbp_config_with_options(config_path, false).await
}
pub async fn load_xbp_config_with_options(
config_path: Option<PathBuf>,
validate_services_cfg: bool,
) -> Result<XbpConfig, String> {
let cwd = std::env::current_dir().unwrap_or_default();
let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
let root = p
.parent()
.map(|parent| {
if parent.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
parent
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| parent.to_path_buf())
} else {
parent.to_path_buf()
}
})
.unwrap_or_else(|| cwd.clone());
(root, p, "auto")
} else {
let found = find_xbp_config_upwards(&cwd)
.ok_or_else(|| "Configuration file not found".to_string())?;
(found.project_root, found.config_path, found.kind)
};
let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
let (config_path, kind) = (resolved_path, resolved_kind);
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config: {}", e))?;
let effective_kind = match kind {
"yaml" | "json" | "jsonc" | "toml" => kind,
_ => config_kind_from_path(&config_path)?,
};
let (mut config, healed_content): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
format!("Failed to parse {effective_kind} config: {e}")
})?;
if let Some(healed_content) = healed_content {
let _ = fs::write(&config_path, healed_content);
}
resolve_config_paths_for_runtime(&mut config, &project_root);
if validate_services_cfg {
if let Some(services) = &config.services {
validate_services(services)?;
}
}
Ok(config)
}
pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
let dir = self.app_dir.join(".xbp");
let json_path = dir.join("xbp.json");
let yaml_path = dir.join("xbp.yaml");
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
let mut xbp_config = XbpConfig {
project_name: self.app_name.clone(),
version: default_xbp_version(),
port: self.port,
build_dir: self.app_dir.to_string_lossy().to_string(),
app_type: None,
build_command: self.build_command.clone(),
start_command: self.start_command.clone(),
install_command: self.install_command.clone(),
environment: if self.environment.is_empty() {
None
} else {
Some(self.environment.clone())
},
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
};
normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
let yaml = serialize_xbp_yaml(&xbp_config)?;
let json = serde_json::to_string_pretty(&xbp_config)
.map_err(|e| format!("Failed to serialize config (json): {}", e))?;
let explicit_path = config_path;
let explicit_is_json = explicit_path
.as_ref()
.and_then(|path| path.extension().and_then(|ext| ext.to_str()))
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false);
fs::write(&yaml_path, &yaml)
.map_err(|e| format!("Failed to write yaml config {}: {}", yaml_path.display(), e))?;
if explicit_is_json {
let out_path = explicit_path.expect("explicit path should exist");
fs::write(&out_path, &json).map_err(|e| {
format!(
"Failed to write legacy JSON config {}: {}",
out_path.display(),
e
)
})?;
} else if json_path.exists() {
fs::write(&json_path, &json).map_err(|e| {
format!(
"Failed to sync legacy JSON config {}: {}",
json_path.display(),
e
)
})?;
}
Ok(())
}
pub fn update_port(&mut self, new_port: u16) {
self.port = new_port;
}
pub fn merge_with_recommendations(
&mut self,
recommendations: &super::project_detector::DeploymentRecommendations,
) {
if self.build_command.is_none() {
self.build_command = recommendations.build_command.clone();
}
if self.start_command.is_none() {
self.start_command = recommendations.start_command.clone();
}
if self.install_command.is_none() {
self.install_command = recommendations.install_command.clone();
}
if let Some(recommended_name) = &recommendations.process_name {
if self.app_name == "app" || self.app_name == "unknown" {
self.app_name = recommended_name.clone();
}
}
}
}
pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
let mut names = std::collections::HashSet::new();
let mut ports = std::collections::HashSet::new();
let mut urls = std::collections::HashSet::new();
for service in services {
if !names.insert(&service.name) {
return Err(format!("Duplicate service name found: {}", service.name));
}
if !ports.insert(service.port) {
return Err(format!("Duplicate port found: {}", service.port));
}
if let Some(url) = &service.url {
if !urls.insert(url) {
return Err(format!("Duplicate URL found: {}", url));
}
}
if service.target.trim().is_empty() {
return Err(format!(
"Service '{}' is missing a target. Set it to something like rust, nextjs, nodejs, python, docker, or a custom runtime label.",
service.name
));
}
}
Ok(())
}
pub fn legacy_service_from_config(config: &XbpConfig) -> ServiceConfig {
ServiceConfig {
name: config.project_name.clone(),
target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
port: config.port,
root_directory: Some(config.build_dir.clone()),
environment: config.environment.clone(),
url: config.url.clone(),
healthcheck_path: None,
restart_policy: Some("on_failure".to_string()),
restart_policy_max_failure_count: Some(10),
start_wrapper: Some("pm2".to_string()),
commands: Some(ServiceCommands {
pre: None,
install: config.install_command.clone(),
build: config.build_command.clone(),
start: config.start_command.clone(),
dev: None,
custom: Default::default(),
}),
force_run_from_root: Some(false),
version_targets: if config.version_targets.is_empty() {
None
} else {
Some(config.version_targets.clone())
},
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: config.systemd_service_name.clone(),
systemd: config.systemd.clone(),
openapi: None,
oci: None,
deploy: None,
}
}
pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
if let Some(services) = &config.services {
services
.iter()
.find(|s| s.name == name)
.cloned()
.ok_or_else(|| format!("Service '{}' not found in configuration", name))
} else {
let legacy = legacy_service_from_config(config);
if legacy.name == name {
Ok(legacy)
} else {
Err(format!(
"Service '{}' not found. Legacy single-service projects expose only '{}'.",
name, legacy.name
))
}
}
}
pub fn get_all_workers(config: &XbpConfig) -> Vec<WorkerConfig> {
config.workers.clone().unwrap_or_default()
}
pub fn get_worker_by_name(config: &XbpConfig, name: &str) -> Result<WorkerConfig, String> {
get_all_workers(config)
.into_iter()
.find(|worker| worker.name == name)
.ok_or_else(|| {
let available = get_all_workers(config)
.iter()
.map(|worker| worker.name.as_str())
.collect::<Vec<_>>()
.join(", ");
if available.is_empty() {
format!(
"Worker app '{name}' was not found. Add a `workers:` section to .xbp/xbp.yaml or run `xbp workers deploy configure --write-config`."
)
} else {
format!(
"Worker app '{name}' was not found. Available workers: {available}"
)
}
})
}
pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
if let Some(services) = &config.services {
services.clone()
} else {
vec![legacy_service_from_config(config)]
}
}
#[cfg(test)]
mod tests {
use super::{
normalize_config_paths_for_persistence, resolve_config_paths_for_runtime,
validate_services, ServiceConfig, XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
};
fn sample_service(name: &str, port: u16) -> ServiceConfig {
ServiceConfig {
name: name.to_string(),
target: "rust".to_string(),
branch: "main".to_string(),
port,
root_directory: None,
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: None,
}
}
#[test]
fn validate_services_rejects_duplicate_ports() {
let services = vec![sample_service("a", 3004), sample_service("b", 3004)];
let err = validate_services(&services).expect_err("duplicate ports");
assert!(err.contains("Duplicate port found: 3004"));
}
#[test]
fn validate_services_allows_unique_ports() {
let services = vec![sample_service("a", 3004), sample_service("b", 3005)];
validate_services(&services).expect("unique ports should pass");
}
#[test]
fn service_versioning_and_release_flags_default_enabled() {
let service = sample_service("api", 3000);
assert!(service.is_versioning_enabled());
assert!(service.is_release_enabled());
let disabled: ServiceConfig = serde_yaml::from_str(
r#"
name: docs
target: nextjs
branch: main
port: 3001
versioning: false
release: false
"#,
)
.expect("parse");
assert!(!disabled.is_versioning_enabled());
assert!(!disabled.is_release_enabled());
let version_only: ServiceConfig = serde_yaml::from_str(
r#"
name: internal
target: rust
branch: main
port: 3002
release: false
"#,
)
.expect("parse");
assert!(version_only.is_versioning_enabled());
assert!(!version_only.is_release_enabled());
}
#[test]
fn project_disable_lists_match_name_and_root_globs() {
let mut service = sample_service("web", 3000);
service.root_directory = Some("apps/web".to_string());
let config = XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: "./".to_string(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: vec!["docs".to_string(), "apps/*".to_string()],
release_disabled: vec!["web".to_string()],
};
assert!(!super::service_versioning_allowed(&config, &service));
assert!(!super::service_release_allowed(&config, &service));
let mut docs = sample_service("docs", 3001);
docs.root_directory = Some("apps/docs".to_string());
assert!(!super::service_versioning_allowed(&config, &docs));
let mut api = sample_service("api", 3002);
api.root_directory = Some("crates/api".to_string());
assert!(super::service_versioning_allowed(&config, &api));
assert!(super::service_release_allowed(&config, &api));
}
#[test]
fn service_commands_accept_arbitrary_named_commands() {
let commands: super::ServiceCommands =
serde_yaml::from_str("build: cargo build\ntest: cargo test\nlint: cargo clippy\n")
.expect("parse service commands");
assert_eq!(
commands.get("build").map(String::as_str),
Some("cargo build")
);
assert_eq!(commands.get("test").map(String::as_str), Some("cargo test"));
assert_eq!(
commands.available_names(),
vec!["build".to_string(), "lint".to_string(), "test".to_string()]
);
}
#[test]
fn parses_systemd_string_shorthand_without_struct_error() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: athena
version: 4.5.0
port: 4052
build_dir: ./
services:
- name: athena-gateway
target: rust
branch: main
port: 4052
systemd: athena.service
systemd_service_name: athena
"#,
)
.expect("parse config");
let service = &config.services.expect("services")[0];
assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
assert!(service.systemd.is_none());
}
#[test]
fn service_config_accepts_systemd_string_shorthand_directly() {
let service: ServiceConfig = serde_yaml::from_str(
r#"
name: athena-gateway
target: rust
branch: main
port: 4052
systemd: athena.service
"#,
)
.expect("parse service");
assert_eq!(
service.systemd_service_name.as_deref(),
Some("athena.service")
);
assert!(service.systemd.is_none());
}
#[test]
fn parses_project_and_service_deploy_oci_blocks() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 1.2.3
port: 3000
build_dir: ./
oci:
registries:
ghcr.io:
provider: github
username: xylex-group
token_source: github
kubernetes:
default_namespace: demo-system
require_context_confirmation_for_apply: true
deploy:
default_env: production
history_dir: .xbp/deployments
groups:
core:
services: [api]
order: [api]
services:
- name: api
target: rust
branch: main
port: 3000
oci:
image: ghcr.io/xylex-group/demo-api
tag_from: project_version
dockerfile: Dockerfile
platforms: [linux/amd64]
deploy:
provider: kubernetes
envs:
production:
namespace: demo-system
replicas: 2
health:
- https://api.example.com/health
kubernetes:
workload: deployment/api
service: api
"#,
)
.expect("parse deploy/oci schema");
let oci = config.oci.expect("project oci");
assert!(oci.registries.contains_key("ghcr.io"));
assert_eq!(
config.kubernetes.as_ref().and_then(|k| k.default_namespace.as_deref()),
Some("demo-system")
);
let group = config
.deploy
.as_ref()
.and_then(|d| d.groups.get("core"))
.expect("group");
assert_eq!(group.services, vec!["api".to_string()]);
let service = config.services.as_ref().unwrap().first().unwrap();
assert_eq!(
service.oci.as_ref().and_then(|o| o.image.as_deref()),
Some("ghcr.io/xylex-group/demo-api")
);
assert_eq!(
service.deploy.as_ref().and_then(|d| d.provider.as_deref()),
Some("kubernetes")
);
let env = service
.deploy
.as_ref()
.and_then(|d| d.envs.get("production"))
.expect("production env");
assert_eq!(env.replicas, Some(2));
assert_eq!(
env.kubernetes
.as_ref()
.and_then(|k| k.workload.as_deref()),
Some("deployment/api")
);
}
#[test]
fn github_auto_push_defaults_to_true_when_missing() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
"#,
)
.expect("parse config");
assert!(config.auto_push_on_commit_enabled());
}
#[test]
fn github_auto_push_can_be_disabled_per_project() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
auto_push_on_commit: false
"#,
)
.expect("parse config");
assert!(!config.auto_push_on_commit_enabled());
}
#[test]
fn github_scalar_repository_shorthand_parses_with_defaults() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github: xylex-group/statbot-js
"#,
)
.expect("parse config");
let github = config.github.expect("github config");
assert_eq!(github.repository.as_deref(), Some("xylex-group/statbot-js"));
assert!(github.auto_push_on_commit);
assert!(github.release_branch.is_none());
}
#[test]
fn github_scalar_repository_shorthand_round_trips_to_scalar_yaml() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github: xylex-group/statbot-js
"#,
)
.expect("parse config");
let yaml = serde_yaml::to_string(&config).expect("serialize config");
assert!(yaml.contains("github: xylex-group/statbot-js"));
}
#[test]
fn github_release_branch_is_disabled_by_default() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
"#,
)
.expect("parse config");
assert!(config.github_release_branch_settings().is_none());
}
#[test]
fn github_release_branch_uses_default_template_when_enabled_without_one() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
release_branch:
enabled: true
"#,
)
.expect("parse config");
let settings = config
.github_release_branch_settings()
.expect("release branch settings");
assert_eq!(
settings.naming_template,
DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE
);
}
#[test]
fn github_release_branch_uses_custom_template_when_configured() {
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
github:
release_branch:
enabled: true
naming_template: rel/${GITHUB_TAG}
"#,
)
.expect("parse config");
let settings = config
.github_release_branch_settings()
.expect("release branch settings");
assert_eq!(settings.naming_template, "rel/${GITHUB_TAG}");
}
#[test]
fn config_version_targets_resolve_for_runtime_and_persist_relatively() {
let project_root = std::env::temp_dir().join("xbp-version-target-config");
let mut config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
version_targets:
- crates/cli/Cargo.toml
- apps/web/package.json
"#,
)
.expect("parse config");
resolve_config_paths_for_runtime(&mut config, &project_root);
assert!(std::path::Path::new(&config.version_targets[0]).ends_with("crates/cli/Cargo.toml"));
assert!(std::path::Path::new(&config.version_targets[1]).ends_with("apps/web/package.json"));
normalize_config_paths_for_persistence(&mut config, &project_root);
assert_eq!(
config.version_targets,
vec![
"crates/cli/Cargo.toml".to_string(),
"apps/web/package.json".to_string()
]
);
}
#[test]
fn worker_container_config_round_trips_and_normalizes_dockerfile() {
let project_root = std::env::temp_dir().join("xbp-worker-container-config");
let mut config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
workers:
- name: auth
root: apps/auth-worker
script_name: demo-auth
container:
class_name: AuthContainer
binding: AUTH_CONTAINER
dockerfile: Dockerfile
port: 8787
application_id: app_123
healthcheck_path: /health
health_urls:
- https://auth.example.com/health
expected_image_name: demo-auth-authcontainer
image_tag_keep_count: 10
allow_unchanged_container_image: false
required_secrets:
- AUTH_SECRET
version_var: AUTH_VERSION
instance_name_template: auth-${VERSION}
default_rollout: gradual
"#,
)
.expect("parse config");
let worker = config
.workers
.as_ref()
.and_then(|workers| workers.first())
.expect("worker");
let container = worker.container.as_ref().expect("container");
assert_eq!(container.class_name.as_deref(), Some("AuthContainer"));
assert_eq!(container.port, Some(8787));
assert_eq!(container.required_secrets, vec!["AUTH_SECRET"]);
assert_eq!(
container.health_urls,
vec!["https://auth.example.com/health"]
);
assert_eq!(
container.expected_image_name.as_deref(),
Some("demo-auth-authcontainer")
);
assert_eq!(container.image_tag_keep_count, Some(10));
assert!(!container.allow_unchanged_container_image);
resolve_config_paths_for_runtime(&mut config, &project_root);
let worker = config
.workers
.as_ref()
.and_then(|workers| workers.first())
.expect("worker");
assert!(std::path::Path::new(&worker.root).ends_with("apps/auth-worker"));
assert!(std::path::Path::new(
worker
.container
.as_ref()
.and_then(|container| container.dockerfile.as_deref())
.expect("dockerfile")
)
.ends_with("apps/auth-worker/Dockerfile"));
normalize_config_paths_for_persistence(&mut config, &project_root);
let yaml = serde_yaml::to_string(&config).expect("serialize config");
assert!(yaml.contains("container:"));
assert!(yaml.contains("class_name: AuthContainer"));
assert!(yaml.contains("dockerfile: Dockerfile"));
}
#[test]
fn legacy_get_service_by_name_resolves_virtual_project_service() {
use super::{get_all_services, get_service_by_name};
let config: XbpConfig = serde_yaml::from_str(
r#"
project_name: "@suitsfinance/formations"
version: 0.7.0
port: 3000
build_dir: ./
target: nextjs
branch: main
build_command: pnpm run build
start_command: pnpm run start
install_command: pnpm install
"#,
)
.expect("parse config");
let listed = get_all_services(&config);
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "@suitsfinance/formations");
let resolved =
get_service_by_name(&config, "@suitsfinance/formations").expect("resolve legacy");
assert_eq!(resolved.name, "@suitsfinance/formations");
assert_eq!(
resolved.commands.as_ref().and_then(|c| c.build.as_deref()),
Some("pnpm run build")
);
let err = get_service_by_name(&config, "missing").expect_err("unknown legacy name");
assert!(err.contains("not found"));
assert!(err.contains("@suitsfinance/formations"));
}
#[test]
fn service_version_targets_resolve_for_runtime_and_persist_relatively() {
let project_root = std::env::temp_dir().join("xbp-service-version-target-config");
let mut config: XbpConfig = serde_yaml::from_str(
r#"
project_name: demo
version: 0.1.0
port: 3000
build_dir: ./
services:
- name: web
target: nextjs
branch: main
port: 3001
root_directory: apps/web
version_targets:
- apps/web/package.json
"#,
)
.expect("parse config");
resolve_config_paths_for_runtime(&mut config, &project_root);
let services = config.services.as_ref().expect("services");
assert!(std::path::Path::new(
services[0]
.version_targets
.as_ref()
.expect("service targets")
.first()
.expect("first target")
)
.ends_with("apps/web/package.json"));
normalize_config_paths_for_persistence(&mut config, &project_root);
assert_eq!(
config.services.unwrap()[0].version_targets,
Some(vec!["apps/web/package.json".to_string()])
);
}
}