use std::path::{Component, Path, PathBuf};
use crate::{
api::{ApiError, ServicePlanRequest},
domain::{
ServiceDefinitionPlan, ServiceLifecycleExecutionReport, ServiceManagerAction,
ServicePackageManifestCheck,
},
identity::stable_hash64,
paths::RuntimePaths,
project::{PROJECT_NAME, SERVICE_LIFECYCLE_CHECKPOINT_FILE_NAME},
storage::StorageTopology,
};
use super::RelayKnowledgeService;
mod checkpoint;
mod execution;
mod forward_steps;
mod platform_service;
mod process_runner;
mod rollback_steps;
mod step_policy;
#[cfg(test)]
use crate::project::{
LINUX_SERVICE_DEFINITION_FILE_NAME, MACOS_SERVICE_DEFINITION_FILE_NAME,
WINDOWS_SERVICE_DEFINITION_FILE_NAME,
};
use checkpoint::write_file;
use execution::{ProcessStepRunner, execute_service_plan_blocking};
use forward_steps::{install_steps, uninstall_steps, upgrade_steps};
use platform_service::{
binary_path, current_platform, install_command, permission_requirements, render_definition,
service_definition_filename, start_command, stop_command, uninstall_command,
};
use rollback_steps::rollback_steps;
#[cfg(test)]
use execution::StepRunner;
#[cfg(all(test, unix))]
use process_runner::run_command_with_timeout;
impl RelayKnowledgeService {
pub(crate) fn render_service_plan_for_request(
&self,
request: &ServicePlanRequest,
) -> Result<ServiceDefinitionPlan, String> {
if request.execute && request.dry_run {
return Err("--execute cannot be combined with --dry-run".to_owned());
}
if !request.execute && !request.dry_run {
return Err("service lifecycle requests must choose dry-run or --execute".to_owned());
}
render_service_plan_for_platform(
&self.runtime.paths,
self.runtime.storage.topology,
request,
current_platform(),
self.runtime.process.current_executable.clone(),
)
}
pub(crate) async fn write_service_definition_from_plan(
&self,
plan: &ServiceDefinitionPlan,
) -> Result<(), ApiError> {
let path = PathBuf::from(&plan.definition_path);
let contents = plan.definition.clone();
tokio::task::spawn_blocking(move || write_file(&path, contents.as_bytes()))
.await
.map_err(|error| ApiError::storage_unavailable(error.to_string()))?
.map_err(|error| ApiError::storage_unavailable(error.to_string()))
}
pub(crate) async fn execute_service_plan(
&self,
plan: &ServiceDefinitionPlan,
) -> Result<ServiceLifecycleExecutionReport, ApiError> {
let plan = plan.clone();
let current_executable = self.runtime.process.current_executable.clone();
let report = tokio::task::spawn_blocking(move || {
let mut runner = ProcessStepRunner::new(current_executable);
execute_service_plan_blocking(&plan, &mut runner)
})
.await
.map_err(|error| ApiError::storage_unavailable(error.to_string()))?;
if let Some(error) = service_execution_error(&report) {
return Err(error);
}
Ok(report)
}
}
fn service_execution_error(report: &ServiceLifecycleExecutionReport) -> Option<ApiError> {
let failed_step_id = report.failed_step_id.as_deref()?;
let rollback_status = if report.rollback_steps.is_empty() {
"not_attempted"
} else if report.rolled_back {
"completed"
} else {
"failed"
};
Some(ApiError::storage_unavailable(format!(
"service lifecycle execution failed at step {failed_step_id}; rollback_status={rollback_status}; completed_steps={}; rollback_steps={}",
report.completed_steps.len(),
report.rollback_steps.len()
)))
}
fn render_service_plan_for_platform(
paths: &RuntimePaths,
topology: StorageTopology,
request: &ServicePlanRequest,
platform: &str,
current_exe: PathBuf,
) -> Result<ServiceDefinitionPlan, String> {
let target_version = normalized_target_version(request.target_version.as_deref())?;
let install_dir = normalized_install_dir(request.install_dir.as_deref())?;
let binary_path = binary_path(platform, install_dir.as_deref(), ¤t_exe);
let definition_path = paths
.service_dir
.join(service_definition_filename(platform));
let checkpoint_path = paths
.service_dir
.join(SERVICE_LIFECYCLE_CHECKPOINT_FILE_NAME);
let definition = render_definition(
platform,
&binary_path.display().to_string(),
&paths.data_dir.display().to_string(),
);
let checksum = format!("{:016x}", stable_hash64(definition.as_bytes()));
let mut runtime_state_paths = vec![
paths.database_file().display().to_string(),
paths.config_dir.display().to_string(),
paths.state_dir.display().to_string(),
paths.log_dir.display().to_string(),
paths.cache_dir.display().to_string(),
];
let mut warnings = vec![
"dry-run is the default; pass --execute to run local file steps and platform service-manager commands".to_owned(),
"runtime state is preserved unless an operator explicitly removes it after reviewing runtime_state_paths".to_owned(),
];
if topology == StorageTopology::PartitionedSqlite {
runtime_state_paths.push(paths.repository_shards_dir().display().to_string());
warnings.push(
"partitioned_sqlite backup, migration, rollback, and uninstall confirmation must include both the control database and repository shard directory"
.to_owned(),
);
}
if request.action == ServiceManagerAction::Rollback {
warnings.push(
"rollback restores checkpointed binary and service definition files when the lifecycle checkpoint exists; data migrations still require their own checkpoint policy"
.to_owned(),
);
}
let install_command = install_command(platform, &definition_path, &binary_path);
let uninstall_command = uninstall_command(platform, &definition_path);
let start_command = start_command(platform);
let stop_command = stop_command(platform);
let context = PlanContext {
platform,
definition_path: &definition_path,
binary_path: &binary_path,
source_binary_path: ¤t_exe,
checkpoint_path: &checkpoint_path,
install_dir: install_dir.as_deref(),
install_command: &install_command,
uninstall_command: &uninstall_command,
start_command: &start_command,
stop_command: &stop_command,
};
let package_manifest_checks = package_manifest_checks(target_version.as_deref());
let lifecycle_steps = match request.action {
ServiceManagerAction::Install => install_steps(&context),
ServiceManagerAction::Upgrade => upgrade_steps(&context),
ServiceManagerAction::Rollback => rollback_steps(request.action, &context),
ServiceManagerAction::Uninstall => uninstall_steps(&context),
};
let rollback_steps = rollback_steps(request.action, &context);
Ok(ServiceDefinitionPlan {
action: request.action,
dry_run: request.dry_run,
platform: platform.to_owned(),
service_name: PROJECT_NAME.to_owned(),
target_version,
install_dir: install_dir.as_ref().map(|path| path.display().to_string()),
binary_path: binary_path.display().to_string(),
definition_path: definition_path.display().to_string(),
install_command,
uninstall_command,
start_command,
stop_command,
lifecycle_steps,
rollback_steps,
permission_requirements: permission_requirements(platform),
package_manifest_checks,
runtime_state_paths,
checkpoint_path: checkpoint_path.display().to_string(),
warnings,
definition,
checksum,
})
}
struct PlanContext<'a> {
platform: &'a str,
definition_path: &'a Path,
binary_path: &'a Path,
source_binary_path: &'a Path,
checkpoint_path: &'a Path,
install_dir: Option<&'a Path>,
install_command: &'a [String],
uninstall_command: &'a [String],
start_command: &'a [String],
stop_command: &'a [String],
}
fn package_manifest_checks(target_version: Option<&str>) -> Vec<ServicePackageManifestCheck> {
let tag = target_version.unwrap_or(env!("CARGO_PKG_VERSION"));
["homebrew", "scoop", "winget", "distro"]
.into_iter()
.map(|manager| ServicePackageManifestCheck {
manager: manager.to_owned(),
artifact_source: format!("GitHub Release tag {tag}"),
verification: "manifest artifacts and checksums must reference the same release tag as the packaged binary".to_owned(),
})
.collect()
}
fn normalized_target_version(value: Option<&str>) -> Result<Option<String>, String> {
value
.map(|version| {
let trimmed = version.trim();
if trimmed.is_empty() {
Err("--target-version must not be empty".to_owned())
} else {
Ok(trimmed.to_owned())
}
})
.transpose()
}
fn normalized_install_dir(value: Option<&str>) -> Result<Option<PathBuf>, String> {
value
.map(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("--install-dir must not be empty".to_owned());
}
let path = PathBuf::from(trimmed);
if !path.is_absolute() {
return Err("--install-dir must be an absolute path".to_owned());
}
if path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("--install-dir must not contain '..'".to_owned());
}
Ok(path)
})
.transpose()
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;