use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use lenso_service::{
ContractSemanticKind, ProviderSemantics, SystemV2Graph, check_contract_artifact_value,
system_v2_graph,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
const DEFAULT_SYSTEM_FILE: &str = "lenso.system.json";
const SERVICE_SYSTEM_PROTOCOL: &str = "lenso.system.v1";
const SYSTEM_RELEASE_PROTOCOL: &str = "lenso.system-release.v1";
const SYSTEM_RUNBOOK_PROTOCOL: &str = "lenso.system-runbook.v1";
const SYSTEM_PLAN_ARTIFACT_VERSION: &str = "lenso.system-plan.v1";
const SYSTEM_GRAPH_ARTIFACT_VERSION: &str = "lenso.system-graph.v1";
const SYSTEM_DRIFT_ARTIFACT_VERSION: &str = "lenso.system-drift.v1";
const MODULE_INSTALLS_PATH: &str = ".lenso/module-installs.json";
const MODULE_SERVICES_PATH: &str = ".lenso/module-services.json";
const SERVICE_ENVIRONMENTS_PATH: &str = ".lenso/service-environments.json";
const SERVICE_DEPLOYMENTS_PATH: &str = ".lenso/service-deployments.json";
const SERVICE_RELEASES_PATH: &str = ".lenso/service-releases.json";
const SYSTEM_RELEASES_PATH: &str = ".lenso/system-releases.json";
const SYSTEM_RUNBOOKS_PATH: &str = ".lenso/system-runbooks.json";
fn machine_result<T>(
result: Result<T>,
json_output: bool,
code: &str,
next_action: &str,
) -> Result<T> {
match result {
Ok(value) => Ok(value),
Err(error) if json_output => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"artifactVersion": "lenso.command-error.v1",
"code": code,
"message": error.to_string(),
"nextAction": next_action,
}))?
);
Err(error)
}
Err(error) => Err(error),
}
}
fn checked_system_v2_graph(artifact: &Value, json_output: bool) -> Result<SystemV2Graph> {
machine_result(
system_v2_graph(artifact).map_err(|issues| anyhow::anyhow!("{issues:?}")),
json_output,
"system_validation_failed",
"Fix the reported System validation issues and rerun the command.",
)
}
fn preflight_system(path: &Path, json_output: bool) -> Result<Value> {
let artifact: Value = machine_result(
fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))
.and_then(|source| {
serde_json::from_str(&source).with_context(|| format!("parse {}", path.display()))
}),
json_output,
"system_artifact_invalid",
"Fix the System artifact JSON and rerun the command.",
)?;
let check = match check_contract_artifact_value(&artifact) {
Ok(check) => check,
Err(error) if json_output => {
println!("{}", serde_json::to_string_pretty(&error)?);
return Err(error.into());
}
Err(error) => return Err(error.into()),
};
if check.semantic_kind == ContractSemanticKind::MixedSystem {
checked_system_v2_graph(&artifact, json_output)?;
}
Ok(artifact)
}
#[derive(Debug, Clone)]
pub(crate) struct SystemInitOptions {
pub(crate) environments: Vec<String>,
pub(crate) force: bool,
pub(crate) name: String,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemAddServiceOptions {
pub(crate) command: Option<String>,
pub(crate) cwd: Option<PathBuf>,
pub(crate) lang: Option<String>,
pub(crate) manifest: Option<String>,
pub(crate) modules: Vec<String>,
pub(crate) name: String,
pub(crate) ready_url: Option<String>,
pub(crate) system_file: Option<PathBuf>,
pub(crate) target: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemAddModuleOptions {
pub(crate) capabilities: Vec<String>,
pub(crate) dependencies: Vec<String>,
pub(crate) install_to: Option<String>,
pub(crate) name: String,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemPlanOptions {
pub(crate) check: bool,
pub(crate) json: bool,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemGraphOptions {
pub(crate) json: bool,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemDiffOptions {
pub(crate) check: bool,
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemApplyOptions {
pub(crate) dry_run: bool,
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemDoctorOptions {
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleasePlanOptions {
pub(crate) allow_drift: bool,
pub(crate) environment_name: String,
pub(crate) json: bool,
pub(crate) output: Option<PathBuf>,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) source_environment: Option<String>,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleaseCheckOptions {
pub(crate) json: bool,
pub(crate) plan_file: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleaseApplyOptions {
pub(crate) dry_run: bool,
pub(crate) json: bool,
pub(crate) plan_file: PathBuf,
pub(crate) repo_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleasePromoteOptions {
pub(crate) from_environment: String,
pub(crate) json: bool,
pub(crate) output: Option<PathBuf>,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) system_file: Option<PathBuf>,
pub(crate) to_environment: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleaseRollbackOptions {
pub(crate) environment_name: String,
pub(crate) json: bool,
pub(crate) output: Option<PathBuf>,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) system_file: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemReleaseHistoryOptions {
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemRunbookGenerateOptions {
pub(crate) json: bool,
pub(crate) output: Option<PathBuf>,
pub(crate) release_plan_file: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemRunbookCheckOptions {
pub(crate) json: bool,
pub(crate) runbook_file: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemRunbookRecordOptions {
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
pub(crate) runbook_file: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemRunbookHistoryOptions {
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct SystemRunbookDoctorOptions {
pub(crate) json: bool,
pub(crate) repo_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ServiceSystem {
protocol: String,
name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
environments: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
services: Vec<SystemService>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
modules: Vec<SystemModule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<SystemDependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemService {
name: String,
target: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
modules: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manifest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ready_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemModule {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
install_to: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemDependency {
from: String,
capability: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemGraph {
artifact_version: String,
artifact_protocol: String,
semantic_kind: ContractSemanticKind,
name: String,
environments: Vec<String>,
services: Vec<SystemGraphService>,
modules: Vec<SystemGraphModule>,
dependencies: Vec<SystemGraphDependency>,
issues: Vec<SystemIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemGraphService {
name: String,
target: String,
modules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemGraphModule {
name: String,
owner: String,
capabilities: Vec<String>,
dependencies: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemGraphDependency {
from: String,
capability: String,
state: String,
to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemIssue {
code: String,
message: String,
next_action: String,
}
impl SystemIssue {
fn new(code: &str, message: impl Into<String>, next_action: &str) -> Self {
Self {
code: code.to_owned(),
message: message.into(),
next_action: next_action.to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApprovalBoundary {
id: String,
category: String,
action: String,
required: bool,
executed: bool,
next_action: String,
}
impl ApprovalBoundary {
fn production_change(id: &str, action: &str) -> Self {
Self {
id: id.to_owned(),
category: "production_impacting".to_owned(),
action: action.to_owned(),
required: true,
executed: false,
next_action: "Obtain explicit operator approval before running this action.".to_owned(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemPlan {
artifact_version: String,
detected_protocol: String,
semantic_kind: ContractSemanticKind,
#[serde(skip_serializing_if = "Option::is_none")]
provider_semantics: Option<ProviderSemantics>,
system_file: String,
name: String,
status: String,
services: usize,
modules: usize,
dependencies: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
kinds: Vec<String>,
issues: Vec<SystemIssue>,
commands: Vec<String>,
next_actions: Vec<String>,
approval_boundaries: Vec<ApprovalBoundary>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemDriftReport {
artifact_version: String,
system_file: String,
repo_root: String,
name: String,
status: String,
graph_issues: Vec<SystemIssue>,
drifts: Vec<SystemDrift>,
commands: Vec<String>,
applied: Vec<String>,
next_actions: Vec<String>,
approval_boundaries: Vec<ApprovalBoundary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemDrift {
code: String,
severity: String,
resource: String,
name: String,
message: String,
command: Option<String>,
next_action: String,
}
#[derive(Debug, Default)]
struct HostSystemState {
installed_modules: BTreeSet<String>,
configured_services: BTreeSet<String>,
environments: BTreeSet<String>,
deployments: BTreeSet<String>,
releases: BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemReleasePlan {
protocol: String,
id: String,
kind: String,
system_name: String,
environment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_environment: Option<String>,
created_at_unix_ms: u64,
status: String,
system_file: String,
services: Vec<SystemReleaseService>,
modules: Vec<String>,
drift_status: String,
graph_issues: Vec<SystemIssue>,
drifts: Vec<SystemDrift>,
policy: SystemReleasePolicy,
rollback_available: bool,
commands: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemReleaseService {
name: String,
target: String,
modules: Vec<String>,
manifest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemReleasePolicy {
risk: String,
issues: Vec<SystemReleasePolicyIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemReleasePolicyIssue {
level: String,
code: String,
message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemRunbook {
protocol: String,
id: String,
release_id: String,
system_name: String,
environment: String,
created_at_unix_ms: u64,
status: String,
steps: Vec<SystemRunbookStep>,
commands: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SystemRunbookStep {
id: String,
kind: String,
title: String,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
manual: bool,
}
pub(crate) fn init_system(options: SystemInitOptions) -> Result<()> {
let path = system_path(options.system_file.as_deref())?;
if path.exists() && !options.force {
bail!(
"Service system already exists: {}. Use --force to replace it.",
path.display()
);
}
let system = ServiceSystem {
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
name: options.name,
environments: options.environments,
services: Vec::new(),
modules: Vec::new(),
dependencies: Vec::new(),
};
write_system(&path, &system)?;
println!("Created service system {}.", path.display());
Ok(())
}
pub(crate) fn add_system_service(options: SystemAddServiceOptions) -> Result<()> {
let path = system_path(options.system_file.as_deref())?;
let mut system = read_or_empty_system(&path)?;
upsert_service(
&mut system,
SystemService {
command: options.command,
cwd: options.cwd.map(|path| path_string(&path)),
lang: options.lang,
manifest: options.manifest,
modules: options.modules,
name: options.name,
ready_url: options.ready_url,
target: options.target,
},
);
write_system(&path, &system)?;
println!("Updated service system {}.", path.display());
Ok(())
}
pub(crate) fn add_system_module(options: SystemAddModuleOptions) -> Result<()> {
let path = system_path(options.system_file.as_deref())?;
let mut system = read_or_empty_system(&path)?;
upsert_module(
&mut system,
SystemModule {
capabilities: options.capabilities,
dependencies: options.dependencies,
install_to: options.install_to,
name: options.name,
},
);
write_system(&path, &system)?;
println!("Updated service system {}.", path.display());
Ok(())
}
pub(crate) fn plan_system(options: SystemPlanOptions) -> Result<()> {
let path = system_read_path(options.system_file.as_deref())?;
let artifact = preflight_system(&path, options.json)?;
let contract_check = match check_contract_artifact_value(&artifact) {
Ok(check) => check,
Err(error) if options.json => {
println!("{}", serde_json::to_string_pretty(&error)?);
bail!("contract check failed");
}
Err(error) => return Err(error.into()),
};
if contract_check.semantic_kind == ContractSemanticKind::MixedSystem {
let graph = checked_system_v2_graph(&artifact, options.json)?;
let kinds = graph
.nodes
.iter()
.map(|node| node.kind.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let plan = SystemPlan {
approval_boundaries: vec![ApprovalBoundary::production_change(
"apply-system-state",
&format!("lenso system apply --system-file {}", path_string(&path)),
)],
artifact_version: SYSTEM_PLAN_ARTIFACT_VERSION.to_owned(),
commands: Vec::new(),
dependencies: graph
.relationships
.iter()
.filter(|relationship| relationship.kind == "consumes")
.count(),
detected_protocol: contract_check.detected_protocol,
issues: Vec::new(),
kinds,
modules: graph
.nodes
.iter()
.filter(|node| node.kind == "module")
.count(),
name: graph.system_id,
next_actions: Vec::new(),
provider_semantics: None,
semantic_kind: contract_check.semantic_kind,
services: graph
.nodes
.iter()
.filter(|node| matches!(node.kind.as_str(), "provider" | "autonomous_service"))
.count(),
status: "ready".to_owned(),
system_file: path_string(&path),
};
if options.json {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
print_system_plan(&plan);
}
return Ok(());
}
let system: ServiceSystem =
serde_json::from_value(artifact).with_context(|| format!("parse {}", path.display()))?;
let graph = system_graph(&system);
let commands = system_commands(&system);
let plan = SystemPlan {
approval_boundaries: vec![ApprovalBoundary::production_change(
"apply-system-state",
&format!("lenso system apply --system-file {}", path_string(&path)),
)],
artifact_version: SYSTEM_PLAN_ARTIFACT_VERSION.to_owned(),
commands,
detected_protocol: contract_check.detected_protocol,
dependencies: graph.dependencies.len(),
issues: graph.issues.clone(),
kinds: Vec::new(),
modules: graph.modules.len(),
name: system.name.clone(),
next_actions: graph
.issues
.iter()
.map(|issue| issue.next_action.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
provider_semantics: contract_check.provider_semantics,
semantic_kind: contract_check.semantic_kind,
services: graph.services.len(),
status: if graph.issues.is_empty() {
"ready".to_owned()
} else {
"needs_attention".to_owned()
},
system_file: path_string(&path),
};
if options.json {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
print_system_plan(&plan);
}
if options.check && !plan.issues.is_empty() {
bail!("Service system plan has issues");
}
Ok(())
}
pub(crate) fn graph_system(options: SystemGraphOptions) -> Result<()> {
let path = system_read_path(options.system_file.as_deref())?;
let artifact = preflight_system(&path, options.json)?;
let check = machine_result(
check_contract_artifact_value(&artifact).map_err(Into::into),
options.json,
"system_contract_invalid",
"Use a supported System protocol and fix the reported contract fields.",
)?;
if check.semantic_kind == ContractSemanticKind::MixedSystem {
let graph = checked_system_v2_graph(&artifact, options.json)?;
if options.json {
let mut output = serde_json::to_value(&graph)?;
output["artifactVersion"] = json!(SYSTEM_GRAPH_ARTIFACT_VERSION);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
print_system_v2_graph(&graph);
}
} else {
let system: ServiceSystem = machine_result(
serde_json::from_value(artifact).with_context(|| format!("parse {}", path.display())),
options.json,
"system_shape_invalid",
"Fix the System fields and rerun `lenso system graph`.",
)?;
let graph = system_graph(&system);
if options.json {
println!("{}", serde_json::to_string_pretty(&graph)?);
} else {
print_system_graph(&graph);
}
}
Ok(())
}
pub(crate) fn diff_system(options: SystemDiffOptions) -> Result<()> {
let path = system_read_path(options.system_file.as_deref())?;
preflight_system(&path, options.json)?;
let report = system_drift_report(
options.system_file.as_deref(),
options.repo_root.as_deref(),
Vec::new(),
)?;
if options.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_system_drift_report(&report, "Service system drift");
}
if options.check && report.status != "ready" {
bail!("Service system has drift");
}
Ok(())
}
pub(crate) fn apply_system(options: SystemApplyOptions) -> Result<()> {
let path = system_read_path(options.system_file.as_deref())?;
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let artifact = preflight_system(&path, options.json)?;
let contract_check = check_contract_artifact_value(&artifact)?;
if contract_check.semantic_kind == ContractSemanticKind::MixedSystem {
let report = system_drift_report(Some(&path), Some(&repo_root), Vec::new())?;
if options.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_system_drift_report(&report, "Service system apply preview");
}
return Ok(());
}
let system = read_system(&path)?;
let mut applied = Vec::new();
applied.extend(apply_module_services(&repo_root, &system, options.dry_run)?);
applied.extend(apply_service_environments(
&repo_root,
&system,
options.dry_run,
)?);
let report = system_drift_report(Some(&path), Some(&repo_root), applied)?;
if options.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_system_drift_report(
&report,
if options.dry_run {
"Service system apply preview"
} else {
"Service system apply"
},
);
}
Ok(())
}
pub(crate) fn doctor_system(options: SystemDoctorOptions) -> Result<()> {
let path = system_read_path(options.system_file.as_deref())?;
preflight_system(&path, options.json)?;
let report = system_drift_report(
options.system_file.as_deref(),
options.repo_root.as_deref(),
Vec::new(),
)?;
if options.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_system_drift_report(&report, "Service system doctor");
if report.status == "ready" {
println!("next: none");
} else if let Some(command) = report
.drifts
.iter()
.find_map(|drift| drift.command.as_ref())
{
println!("next: {command}");
}
}
Ok(())
}
pub(crate) fn plan_system_release(options: SystemReleasePlanOptions) -> Result<()> {
let plan = build_system_release_plan("release", &options)?;
write_system_release_plan(options.output.as_deref(), &plan)?;
print_or_json_system_release_plan(&plan, options.json);
Ok(())
}
pub(crate) fn check_system_release(options: SystemReleaseCheckOptions) -> Result<()> {
let plan = read_system_release_plan(&options.plan_file)?;
print_or_json_system_release_plan(&plan, options.json);
if plan.status == "blocked" {
bail!("System release is blocked");
}
Ok(())
}
pub(crate) fn apply_system_release(options: SystemReleaseApplyOptions) -> Result<()> {
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let plan = read_system_release_plan(&options.plan_file)?;
if plan.status == "blocked" {
bail!("System release is blocked");
}
let applied = if options.dry_run {
format!(
"would record {} in {}",
plan.id,
display_relative(&repo_root, &repo_root.join(SYSTEM_RELEASES_PATH))
)
} else {
append_system_release_history(&repo_root, &plan)?;
format!(
"recorded {} in {}",
plan.id,
display_relative(&repo_root, &repo_root.join(SYSTEM_RELEASES_PATH))
)
};
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"applied": applied,
"dryRun": options.dry_run,
"plan": plan,
"version": 1,
}))?
);
} else {
println!("System release apply: {applied}");
}
Ok(())
}
pub(crate) fn promote_system_release(options: SystemReleasePromoteOptions) -> Result<()> {
let plan_options = SystemReleasePlanOptions {
allow_drift: false,
environment_name: options.to_environment,
json: options.json,
output: options.output,
repo_root: options.repo_root,
source_environment: Some(options.from_environment),
system_file: options.system_file,
};
let plan = build_system_release_plan("promote", &plan_options)?;
write_system_release_plan(plan_options.output.as_deref(), &plan)?;
print_or_json_system_release_plan(&plan, plan_options.json);
Ok(())
}
pub(crate) fn rollback_system_release(options: SystemReleaseRollbackOptions) -> Result<()> {
let plan_options = SystemReleasePlanOptions {
allow_drift: true,
environment_name: options.environment_name,
json: options.json,
output: options.output,
repo_root: options.repo_root,
source_environment: None,
system_file: options.system_file,
};
let plan = build_system_release_plan("rollback", &plan_options)?;
write_system_release_plan(plan_options.output.as_deref(), &plan)?;
print_or_json_system_release_plan(&plan, plan_options.json);
Ok(())
}
pub(crate) fn history_system_release(options: SystemReleaseHistoryOptions) -> Result<()> {
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let releases = read_system_release_history(&repo_root)?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"releases": releases,
"version": 1,
}))?
);
} else if releases.is_empty() {
println!("No system releases recorded.");
} else {
println!("System releases:");
for release in releases {
let id = release.get("id").and_then(Value::as_str).unwrap_or("-");
let env = release
.get("environment")
.and_then(Value::as_str)
.unwrap_or("-");
let system = release
.get("systemName")
.and_then(Value::as_str)
.unwrap_or("-");
println!(" {id}: {system}/{env}");
}
}
Ok(())
}
pub(crate) fn generate_system_runbook(options: SystemRunbookGenerateOptions) -> Result<()> {
let release = read_system_release_plan(&options.release_plan_file)?;
let runbook = build_system_runbook(&release, &options.release_plan_file)?;
write_system_runbook(options.output.as_deref(), &runbook)?;
print_or_json_system_runbook(&runbook, options.json);
Ok(())
}
pub(crate) fn check_system_runbook(options: SystemRunbookCheckOptions) -> Result<()> {
let runbook = read_system_runbook(&options.runbook_file)?;
print_or_json_system_runbook(&runbook, options.json);
if runbook.status == "blocked" {
bail!("System runbook is blocked");
}
Ok(())
}
pub(crate) fn record_system_runbook(options: SystemRunbookRecordOptions) -> Result<()> {
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let runbook = read_system_runbook(&options.runbook_file)?;
append_system_runbook_history(&repo_root, &runbook)?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"recorded": true,
"runbookId": runbook.id,
"path": display_relative(&repo_root, &repo_root.join(SYSTEM_RUNBOOKS_PATH)),
}))?
);
} else {
println!(
"System runbook record: recorded {} in {}",
runbook.id,
display_relative(&repo_root, &repo_root.join(SYSTEM_RUNBOOKS_PATH))
);
}
Ok(())
}
pub(crate) fn history_system_runbook(options: SystemRunbookHistoryOptions) -> Result<()> {
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let runbooks = read_system_runbook_history(&repo_root)?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"runbooks": runbooks,
"version": 1,
}))?
);
} else if runbooks.is_empty() {
println!("No system runbooks recorded.");
} else {
println!("System runbooks:");
for runbook in runbooks {
let id = string_field(&runbook, "id").unwrap_or_else(|| "<unknown>".to_owned());
let system = string_field(&runbook, "systemName").unwrap_or_else(|| "-".to_owned());
let env = string_field(&runbook, "environment").unwrap_or_else(|| "-".to_owned());
let status = string_field(&runbook, "status").unwrap_or_else(|| "-".to_owned());
println!(" {id}: {system}/{env} {status}");
}
}
Ok(())
}
pub(crate) fn doctor_system_runbook(options: SystemRunbookDoctorOptions) -> Result<()> {
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let runbooks = read_system_runbook_history(&repo_root)?;
if options.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": if runbooks.is_empty() { "empty" } else { "ready" },
"runbooks": runbooks,
"version": 1,
}))?
);
} else if runbooks.is_empty() {
println!("System runbook doctor: empty");
println!(
"next: lenso system runbook generate system-release.json --output system-runbook.json"
);
} else {
println!("System runbook doctor: ready");
println!("next: lenso system runbook history");
}
Ok(())
}
fn upsert_service(system: &mut ServiceSystem, service: SystemService) {
if let Some(existing) = system
.services
.iter_mut()
.find(|existing| existing.name == service.name)
{
*existing = service;
} else {
system.services.push(service);
}
system.services.sort_by(|a, b| a.name.cmp(&b.name));
}
fn upsert_module(system: &mut ServiceSystem, module: SystemModule) {
if let Some(existing) = system
.modules
.iter_mut()
.find(|existing| existing.name == module.name)
{
*existing = module;
} else {
system.modules.push(module);
}
system.modules.sort_by(|a, b| a.name.cmp(&b.name));
}
fn system_graph(system: &ServiceSystem) -> SystemGraph {
let services_by_name = system
.services
.iter()
.map(|service| (service.name.as_str(), service))
.collect::<BTreeMap<_, _>>();
let modules_by_name = system
.modules
.iter()
.map(|module| (module.name.as_str(), module))
.collect::<BTreeMap<_, _>>();
let mut module_owner = BTreeMap::new();
let mut issues = Vec::new();
for service in &system.services {
for module_name in &service.modules {
if !modules_by_name.contains_key(module_name.as_str()) {
issues.push(SystemIssue::new(
"module_not_declared",
format!(
"Service `{}` references undeclared module `{module_name}`.",
service.name
),
"Declare the module and rerun `lenso system check`.",
));
}
if let Some(existing) = module_owner.insert(module_name.as_str(), service.name.as_str())
{
issues.push(SystemIssue::new(
"module_owned_twice",
format!(
"Module `{module_name}` is assigned to both `{existing}` and `{}`.",
service.name
),
"Assign the module to exactly one owner and rerun `lenso system check`.",
));
}
}
}
for module in &system.modules {
if let Some(service_name) = module
.install_to
.as_deref()
.and_then(|install_to| install_to.strip_prefix("service:"))
&& !services_by_name.contains_key(service_name)
{
issues.push(SystemIssue::new(
"install_target_missing",
format!(
"Module `{}` installs to missing service `{service_name}`.",
module.name
),
"Declare the target service or choose an existing install target.",
));
}
}
let capability_owners = capability_owners(system, &module_owner);
let mut dependencies = Vec::new();
for module in &system.modules {
let from = module_owner_name(module, &module_owner);
for capability in &module.dependencies {
dependencies.push(dependency_edge(
from,
capability,
capability_owners
.get(capability.as_str())
.map(Vec::as_slice),
));
}
}
for dependency in &system.dependencies {
if let Some(to) = dependency.to.as_deref() {
let target_exists =
services_by_name.contains_key(to) || modules_by_name.contains_key(to);
let target_has_capability = target_owns_capability(
to,
&dependency.capability,
&capability_owners,
&modules_by_name,
);
dependencies.push(SystemGraphDependency {
capability: dependency.capability.clone(),
from: dependency.from.clone(),
state: if !target_exists {
"unresolved".to_owned()
} else if target_has_capability {
"resolved".to_owned()
} else {
"missing_capability".to_owned()
},
to: Some(to.to_owned()),
});
} else {
dependencies.push(dependency_edge(
&dependency.from,
&dependency.capability,
capability_owners
.get(dependency.capability.as_str())
.map(Vec::as_slice),
));
}
}
for dependency in &dependencies {
if dependency.state != "resolved" {
issues.push(SystemIssue::new(
&format!("dependency_{}", dependency.state),
format!(
"`{}` depends on `{}`, but it is {}.",
dependency.from, dependency.capability, dependency.state
),
"Declare exactly one matching capability provider and rerun `lenso system check`.",
));
}
}
dependencies
.sort_by(|a, b| (&a.from, &a.capability, &a.to).cmp(&(&b.from, &b.capability, &b.to)));
issues.sort_by(|a, b| (&a.code, &a.message).cmp(&(&b.code, &b.message)));
let mut environments = system.environments.clone();
environments.sort();
environments.dedup();
let mut modules = system
.modules
.iter()
.map(|module| {
let mut capabilities = module.capabilities.clone();
capabilities.sort();
capabilities.dedup();
let mut dependencies = module.dependencies.clone();
dependencies.sort();
dependencies.dedup();
SystemGraphModule {
capabilities,
dependencies,
name: module.name.clone(),
owner: module_owner_name(module, &module_owner).to_owned(),
}
})
.collect::<Vec<_>>();
modules.sort_by(|a, b| a.name.cmp(&b.name));
let mut services = system
.services
.iter()
.map(|service| {
let mut modules = service.modules.clone();
modules.sort();
modules.dedup();
SystemGraphService {
modules,
name: service.name.clone(),
target: service.target.clone(),
}
})
.collect::<Vec<_>>();
services.sort_by(|a, b| a.name.cmp(&b.name));
SystemGraph {
artifact_version: SYSTEM_GRAPH_ARTIFACT_VERSION.to_owned(),
artifact_protocol: system.protocol.clone(),
dependencies,
environments,
issues,
modules,
name: system.name.clone(),
semantic_kind: ContractSemanticKind::ProviderSystem,
services,
}
}
fn capability_owners<'a>(
system: &'a ServiceSystem,
module_owner: &BTreeMap<&'a str, &'a str>,
) -> BTreeMap<&'a str, Vec<&'a str>> {
let mut owners: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for module in &system.modules {
let owner = module_owner_name(module, module_owner);
for capability in &module.capabilities {
owners.entry(capability.as_str()).or_default().push(owner);
}
}
owners
}
fn target_owns_capability(
target: &str,
capability: &str,
capability_owners: &BTreeMap<&str, Vec<&str>>,
modules_by_name: &BTreeMap<&str, &SystemModule>,
) -> bool {
capability_owners
.get(capability)
.is_some_and(|owners| owners.iter().any(|owner| *owner == target))
|| modules_by_name.get(target).is_some_and(|module| {
module
.capabilities
.iter()
.any(|provided| provided == capability)
})
}
fn dependency_edge(from: &str, capability: &str, owners: Option<&[&str]>) -> SystemGraphDependency {
let (state, to) = match owners {
Some(owners) if owners.len() == 1 => ("resolved", Some(owners[0].to_owned())),
Some(owners) if owners.len() > 1 => ("ambiguous", Some(owners.join(","))),
_ => ("unresolved", None),
};
SystemGraphDependency {
capability: capability.to_owned(),
from: from.to_owned(),
state: state.to_owned(),
to,
}
}
fn install_owner(module: &SystemModule) -> Option<&str> {
let install_to = module.install_to.as_deref()?;
install_to.strip_prefix("service:").or(Some(install_to))
}
fn module_owner_name<'a>(
module: &'a SystemModule,
module_owner: &BTreeMap<&'a str, &'a str>,
) -> &'a str {
module_owner
.get(module.name.as_str())
.copied()
.or_else(|| install_owner(module))
.unwrap_or("host")
}
fn system_commands(system: &ServiceSystem) -> Vec<String> {
let mut commands = Vec::new();
for service in &system.services {
if let Some(command) = service_workspace_command(service) {
commands.push(command);
}
for environment in &system.environments {
if matches!(service.target.as_str(), "kubernetes" | "operator") {
commands.push(format!(
"lenso service env add {} --service {} --target {}",
shell_word(environment),
shell_word(&service.name),
shell_word(&service.target)
));
}
}
}
commands.sort();
commands.dedup();
commands
}
fn system_drift_report(
system_file: Option<&Path>,
repo_root: Option<&Path>,
applied: Vec<String>,
) -> Result<SystemDriftReport> {
let path = system_read_path(system_file)?;
let repo_root = repo_root_path(repo_root)?;
let artifact: Value = serde_json::from_str(
&fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?,
)
.with_context(|| format!("parse {}", path.display()))?;
let contract_check = check_contract_artifact_value(&artifact)?;
if contract_check.semantic_kind == ContractSemanticKind::MixedSystem {
let graph = system_v2_graph(&artifact).map_err(|issues| anyhow::anyhow!("{issues:?}"))?;
return Ok(SystemDriftReport {
approval_boundaries: vec![ApprovalBoundary::production_change(
"apply-system-state",
&format!("lenso system apply --system-file {}", path_string(&path)),
)],
applied,
artifact_version: SYSTEM_DRIFT_ARTIFACT_VERSION.to_owned(),
commands: Vec::new(),
drifts: Vec::new(),
graph_issues: Vec::new(),
name: graph.system_id,
next_actions: Vec::new(),
repo_root: path_string(&repo_root),
status: "ready".to_owned(),
system_file: path_string(&path),
});
}
let system = read_system(&path)?;
let graph = system_graph(&system);
let state = read_host_system_state(&repo_root)?;
let mut drifts = system_drifts(&system, &graph, &state);
drifts.sort_by(|a, b| (&a.resource, &a.name, &a.code).cmp(&(&b.resource, &b.name, &b.code)));
let commands = drifts
.iter()
.filter_map(|drift| drift.command.clone())
.collect::<Vec<_>>();
let status = if !graph.issues.is_empty() {
"needs_attention"
} else if !drifts.is_empty() {
"drifted"
} else {
"ready"
};
let next_actions = graph
.issues
.iter()
.map(|issue| issue.next_action.clone())
.chain(drifts.iter().map(|drift| drift.next_action.clone()))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
Ok(SystemDriftReport {
approval_boundaries: vec![ApprovalBoundary::production_change(
"apply-system-state",
&format!("lenso system apply --system-file {}", path_string(&path)),
)],
artifact_version: SYSTEM_DRIFT_ARTIFACT_VERSION.to_owned(),
system_file: path_string(&path),
repo_root: path_string(&repo_root),
name: system.name,
status: status.to_owned(),
graph_issues: graph.issues,
drifts,
commands,
applied,
next_actions,
})
}
fn system_drifts(
system: &ServiceSystem,
graph: &SystemGraph,
state: &HostSystemState,
) -> Vec<SystemDrift> {
let mut drifts = Vec::new();
for service in &system.services {
if !state.configured_services.contains(&service.name) {
drifts.push(SystemDrift {
code: "service_not_configured".to_owned(),
severity: "warning".to_owned(),
resource: "service".to_owned(),
name: service.name.clone(),
message: format!("Service `{}` is declared but not configured.", service.name),
command: service_workspace_command(service),
next_action: "Configure the service locally, then rerun `lenso system doctor`."
.to_owned(),
});
}
if matches!(service.target.as_str(), "kubernetes" | "operator") {
for environment in &system.environments {
let key = service_environment_key(&service.name, environment);
if !state.environments.contains(&key) {
drifts.push(SystemDrift {
code: "service_env_missing".to_owned(),
severity: "warning".to_owned(),
resource: "environment".to_owned(),
name: key.clone(),
message: format!(
"Service `{}` has no `{environment}` environment state.",
service.name
),
command: Some(format!(
"lenso service env add {} --service {} --target {}",
shell_word(environment),
shell_word(&service.name),
shell_word(&service.target)
)),
next_action: "Create the declared environment configuration, then rerun `lenso system doctor`.".to_owned(),
});
} else if !state.deployments.contains(&key) {
drifts.push(SystemDrift {
code: "deployment_state_missing".to_owned(),
severity: "info".to_owned(),
resource: "deployment".to_owned(),
name: key.clone(),
message: format!(
"Service `{}` has `{environment}` env state but no deployment observation.",
service.name
),
command: Some(format!(
"lenso service deploy status {} --env {} --source {} --write-state",
shell_word(&service.name),
shell_word(environment),
shell_word(&service.target)
)),
next_action: "Record a deployment observation, then rerun `lenso system doctor`.".to_owned(),
});
}
if !state.releases.contains(&key) {
drifts.push(SystemDrift {
code: "release_state_missing".to_owned(),
severity: "info".to_owned(),
resource: "release".to_owned(),
name: key,
message: format!(
"Service `{}` has no `{environment}` release record.",
service.name
),
command: Some(format!(
"lenso service release plan {} <manifest-or-package> --env {} --output release-plan.json",
shell_word(&service.name),
shell_word(environment)
)),
next_action: "Create and review a release plan before recording a release.".to_owned(),
});
}
}
}
}
for module in &graph.modules {
if !state.installed_modules.contains(&module.name) {
drifts.push(SystemDrift {
code: "module_not_installed".to_owned(),
severity: "warning".to_owned(),
resource: "module".to_owned(),
name: module.name.clone(),
message: format!("Module `{}` is declared but not installed.", module.name),
command: Some(format!("lenso module install {}", shell_word(&module.name))),
next_action:
"Review and install the declared module, then rerun `lenso system doctor`."
.to_owned(),
});
}
}
drifts
}
fn read_host_system_state(repo_root: &Path) -> Result<HostSystemState> {
Ok(HostSystemState {
installed_modules: read_installed_modules(&repo_root.join(MODULE_INSTALLS_PATH))?,
configured_services: read_configured_services(&repo_root.join(MODULE_SERVICES_PATH))?,
environments: read_service_environment_keys(&repo_root.join(SERVICE_ENVIRONMENTS_PATH))?,
deployments: read_service_deployment_keys(&repo_root.join(SERVICE_DEPLOYMENTS_PATH))?,
releases: read_service_release_keys(&repo_root.join(SERVICE_RELEASES_PATH))?,
})
}
fn read_installed_modules(path: &Path) -> Result<BTreeSet<String>> {
Ok(read_json_if_exists(path)?
.and_then(|value| value.get("modules").and_then(Value::as_array).cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|module| string_field(&module, "moduleName"))
.collect())
}
fn read_configured_services(path: &Path) -> Result<BTreeSet<String>> {
let mut services = BTreeSet::new();
let modules = read_json_if_exists(path)?
.and_then(|value| value.get("modules").and_then(Value::as_array).cloned())
.unwrap_or_default();
for module in modules {
if let Some(module_name) = string_field(&module, "moduleName") {
services.insert(module_name);
}
for service in module
.get("services")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
{
if let Some(name) = string_field(&service, "name") {
services.insert(name);
}
}
}
Ok(services)
}
fn read_service_environment_keys(path: &Path) -> Result<BTreeSet<String>> {
Ok(read_json_if_exists(path)?
.and_then(|value| value.get("environments").and_then(Value::as_array).cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|environment| service_env_key_from_value(&environment))
.collect())
}
fn read_service_deployment_keys(path: &Path) -> Result<BTreeSet<String>> {
Ok(read_json_if_exists(path)?
.and_then(|value| value.get("observations").and_then(Value::as_array).cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|observation| service_env_key_from_value(&observation))
.collect())
}
fn read_service_release_keys(path: &Path) -> Result<BTreeSet<String>> {
Ok(read_json_if_exists(path)?
.and_then(|value| value.get("releases").and_then(Value::as_array).cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|release| {
let service = string_field(&release, "serviceName")?;
let environment = release
.get("environment")
.and_then(|environment| string_field(environment, "name"))
.unwrap_or_else(|| "default".to_owned());
Some(service_environment_key(&service, &environment))
})
.collect())
}
fn apply_module_services(
repo_root: &Path,
system: &ServiceSystem,
dry_run: bool,
) -> Result<Vec<String>> {
let path = repo_root.join(MODULE_SERVICES_PATH);
let mut file =
read_json_if_exists(&path)?.unwrap_or_else(|| json!({ "modules": [], "version": 1 }));
if !file.get("modules").is_some_and(Value::is_array) {
file["modules"] = json!([]);
}
let mut applied = Vec::new();
for service in &system.services {
let Some(plan) = module_service_plan(service) else {
continue;
};
for module_name in &service.modules {
upsert_module_service_plan(&mut file, module_name, plan.clone())?;
applied.push(format!(
"{} {}",
if dry_run { "would update" } else { "updated" },
display_relative(repo_root, &path)
));
}
}
if !dry_run && !applied.is_empty() {
write_json(&path, &file)?;
}
applied.sort();
applied.dedup();
Ok(applied)
}
fn apply_service_environments(
repo_root: &Path,
system: &ServiceSystem,
dry_run: bool,
) -> Result<Vec<String>> {
let path = repo_root.join(SERVICE_ENVIRONMENTS_PATH);
let mut file =
read_json_if_exists(&path)?.unwrap_or_else(|| json!({ "environments": [], "version": 1 }));
if !file.get("environments").is_some_and(Value::is_array) {
file["environments"] = json!([]);
}
let mut applied = Vec::new();
for service in &system.services {
if !matches!(service.target.as_str(), "kubernetes" | "operator") {
continue;
}
for environment in &system.environments {
upsert_service_environment(&mut file, service, environment)?;
applied.push(format!(
"{} {}",
if dry_run { "would update" } else { "updated" },
display_relative(repo_root, &path)
));
}
}
if !dry_run && !applied.is_empty() {
write_json(&path, &file)?;
}
applied.sort();
applied.dedup();
Ok(applied)
}
fn build_system_release_plan(
kind: &str,
options: &SystemReleasePlanOptions,
) -> Result<SystemReleasePlan> {
let system_path = system_read_path(options.system_file.as_deref())?;
let repo_root = repo_root_path(options.repo_root.as_deref())?;
let system = read_system(&system_path)?;
let graph = system_graph(&system);
let state = read_host_system_state(&repo_root)?;
let drifts = release_drifts(
&system_drifts(&system, &graph, &state),
&options.environment_name,
);
let history = read_system_release_history(&repo_root)?;
let rollback_available = has_system_release_for_env(&history, &options.environment_name);
let mut policy_issues = Vec::new();
for issue in &graph.issues {
policy_issues.push(SystemReleasePolicyIssue {
code: issue.code.clone(),
level: "error".to_owned(),
message: issue.message.clone(),
});
}
for drift in &drifts {
let level = match drift.code.as_str() {
"module_not_installed" | "service_not_configured" | "service_env_missing" => "error",
"deployment_state_missing" if !options.allow_drift => "error",
_ => "info",
};
if level == "error" {
policy_issues.push(SystemReleasePolicyIssue {
code: drift.code.clone(),
level: level.to_owned(),
message: drift.message.clone(),
});
}
}
if let Some(source) = options.source_environment.as_deref()
&& !has_system_release_for_env(&history, source)
{
policy_issues.push(SystemReleasePolicyIssue {
code: "source_system_release_missing".to_owned(),
level: "error".to_owned(),
message: format!("No applied system release found for `{source}`."),
});
}
if kind == "rollback" && !rollback_available {
policy_issues.push(SystemReleasePolicyIssue {
code: "rollback_unavailable".to_owned(),
level: "error".to_owned(),
message: format!(
"No applied system release found for `{}`.",
options.environment_name
),
});
}
let risk = if policy_issues.iter().any(|issue| issue.level == "error") {
"blocked"
} else if policy_issues.is_empty() {
"safe"
} else {
"needs_attention"
};
let status = if risk == "blocked" {
"blocked"
} else {
"ready"
};
let commands = system_release_commands(&system, &options.environment_name, kind);
Ok(SystemReleasePlan {
protocol: SYSTEM_RELEASE_PROTOCOL.to_owned(),
id: format!(
"sysrel_{}_{}",
options.environment_name,
uuid::Uuid::now_v7().simple()
),
kind: kind.to_owned(),
system_name: system.name,
environment: options.environment_name.clone(),
source_environment: options.source_environment.clone(),
created_at_unix_ms: current_time_millis()?,
status: status.to_owned(),
system_file: path_string(
options
.system_file
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_SYSTEM_FILE)),
),
services: system
.services
.into_iter()
.map(|service| SystemReleaseService {
manifest: service.manifest,
modules: service.modules,
name: service.name,
target: service.target,
})
.collect(),
modules: graph
.modules
.into_iter()
.map(|module| module.name)
.collect(),
drift_status: if drifts.is_empty() {
"ready".to_owned()
} else {
"drifted".to_owned()
},
graph_issues: graph.issues,
drifts,
policy: SystemReleasePolicy {
risk: risk.to_owned(),
issues: policy_issues,
},
rollback_available,
commands,
})
}
fn release_drifts(drifts: &[SystemDrift], environment: &str) -> Vec<SystemDrift> {
drifts
.iter()
.filter(|drift| {
matches!(drift.resource.as_str(), "module" | "service")
|| drift.name.ends_with(&format!("/{environment}"))
})
.cloned()
.collect()
}
fn system_release_commands(system: &ServiceSystem, environment: &str, kind: &str) -> Vec<String> {
let mut commands = Vec::new();
for service in &system.services {
if matches!(service.target.as_str(), "kubernetes" | "operator") {
match kind {
"rollback" => commands.push(format!(
"lenso service release rollback {} --env {} --output release-rollback-{}.json",
shell_word(&service.name),
shell_word(environment),
shell_word(&service.name)
)),
_ => commands.push(format!(
"lenso service release plan {} <manifest-or-package> --env {} --output release-plan-{}.json",
shell_word(&service.name),
shell_word(environment),
shell_word(&service.name)
)),
}
commands.push(format!(
"lenso service deploy status {} --env {} --source {} --write-state",
shell_word(&service.name),
shell_word(environment),
shell_word(&service.target)
));
}
}
commands
}
fn write_system_release_plan(output: Option<&Path>, plan: &SystemReleasePlan) -> Result<()> {
if let Some(output) = output {
write_json(output, &serde_json::to_value(plan)?)
.with_context(|| format!("write {}", output.display()))?;
println!("Wrote system release plan {}.", output.display());
}
Ok(())
}
fn read_system_release_plan(path: &Path) -> Result<SystemReleasePlan> {
let source = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let plan = serde_json::from_str::<SystemReleasePlan>(&source)
.with_context(|| format!("parse {}", path.display()))?;
if plan.protocol != SYSTEM_RELEASE_PROTOCOL {
bail!(
"System release plan {} uses unsupported protocol `{}`",
path.display(),
plan.protocol
);
}
Ok(plan)
}
fn print_or_json_system_release_plan(plan: &SystemReleasePlan, json_output: bool) {
if json_output {
println!("{}", serde_json::to_string_pretty(plan).unwrap());
return;
}
println!(
"System release: {} {} ({})",
plan.system_name, plan.environment, plan.status
);
println!("id: {}", plan.id);
println!("kind: {}", plan.kind);
if let Some(source) = plan.source_environment.as_deref() {
println!("from: {source}");
}
println!(
"services: {} / modules: {} / drift: {}",
plan.services.len(),
plan.modules.len(),
plan.drift_status
);
println!("policy: {}", plan.policy.risk);
for issue in &plan.policy.issues {
println!(" - [{}] {}: {}", issue.level, issue.code, issue.message);
}
if !plan.commands.is_empty() {
println!("commands:");
for command in &plan.commands {
println!(" {command}");
}
}
}
fn append_system_release_history(repo_root: &Path, plan: &SystemReleasePlan) -> Result<()> {
let path = repo_root.join(SYSTEM_RELEASES_PATH);
let mut ledger =
read_json_if_exists(&path)?.unwrap_or_else(|| json!({ "releases": [], "version": 1 }));
if !ledger.get("releases").is_some_and(Value::is_array) {
ledger["releases"] = json!([]);
}
let mut record = serde_json::to_value(plan)?;
record["appliedAtUnixMs"] = json!(current_time_millis()?);
ledger
.get_mut("releases")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("system releases must be an array"))?
.push(record);
write_json(&path, &ledger)
}
fn read_system_release_history(repo_root: &Path) -> Result<Vec<Value>> {
Ok(read_json_if_exists(&repo_root.join(SYSTEM_RELEASES_PATH))?
.and_then(|value| value.get("releases").and_then(Value::as_array).cloned())
.unwrap_or_default())
}
fn has_system_release_for_env(releases: &[Value], environment: &str) -> bool {
releases.iter().any(|release| {
string_field(release, "environment").as_deref() == Some(environment)
&& string_field(release, "status").as_deref() == Some("ready")
})
}
fn build_system_runbook(
release: &SystemReleasePlan,
release_plan_file: &Path,
) -> Result<SystemRunbook> {
let release_plan = path_string(release_plan_file);
let mut steps = vec![SystemRunbookStep {
command: Some(format!(
"lenso system release check {}",
shell_word(&release_plan)
)),
id: "check-release".to_owned(),
kind: "evidence".to_owned(),
manual: false,
status: "pending".to_owned(),
title: "Check system release".to_owned(),
}];
if release.status == "blocked" {
steps.push(SystemRunbookStep {
command: None,
id: "resolve-policy".to_owned(),
kind: "manual".to_owned(),
manual: true,
status: "blocked".to_owned(),
title: "Resolve system release policy".to_owned(),
});
} else {
for (index, command) in release.commands.iter().enumerate() {
let kind = if command.contains(" deploy status ") {
"service_deploy"
} else if command.contains(" service release ") {
"service_release"
} else {
"evidence"
};
steps.push(SystemRunbookStep {
command: Some(command.clone()),
id: format!("step-{}", index + 1),
kind: kind.to_owned(),
manual: false,
status: "pending".to_owned(),
title: match kind {
"service_deploy" => "Record service deployment evidence",
"service_release" => "Prepare service release",
_ => "Run release command",
}
.to_owned(),
});
}
steps.push(SystemRunbookStep {
command: Some(format!(
"lenso system release apply {} --repo-root .",
shell_word(&release_plan)
)),
id: "record-release".to_owned(),
kind: "evidence".to_owned(),
manual: false,
status: "pending".to_owned(),
title: "Record system release".to_owned(),
});
}
let commands = steps
.iter()
.filter_map(|step| step.command.clone())
.collect::<Vec<_>>();
Ok(SystemRunbook {
commands,
created_at_unix_ms: current_time_millis()?,
environment: release.environment.clone(),
id: format!(
"sysrun_{}_{}",
release.environment,
uuid::Uuid::now_v7().simple()
),
protocol: SYSTEM_RUNBOOK_PROTOCOL.to_owned(),
release_id: release.id.clone(),
status: if release.status == "blocked" {
"blocked".to_owned()
} else {
"ready".to_owned()
},
steps,
system_name: release.system_name.clone(),
})
}
fn write_system_runbook(output: Option<&Path>, runbook: &SystemRunbook) -> Result<()> {
if let Some(output) = output {
write_json(output, &serde_json::to_value(runbook)?)
.with_context(|| format!("write {}", output.display()))?;
println!("Wrote system runbook {}.", output.display());
}
Ok(())
}
fn read_system_runbook(path: &Path) -> Result<SystemRunbook> {
let source = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let runbook = serde_json::from_str::<SystemRunbook>(&source)
.with_context(|| format!("parse {}", path.display()))?;
if runbook.protocol != SYSTEM_RUNBOOK_PROTOCOL {
bail!(
"System runbook {} uses unsupported protocol `{}`",
path.display(),
runbook.protocol
);
}
Ok(runbook)
}
fn print_or_json_system_runbook(runbook: &SystemRunbook, json_output: bool) {
if json_output {
println!("{}", serde_json::to_string_pretty(runbook).unwrap());
return;
}
println!(
"System runbook: {} {} ({})",
runbook.system_name, runbook.environment, runbook.status
);
println!("id: {}", runbook.id);
println!("release: {}", runbook.release_id);
println!("steps: {}", runbook.steps.len());
if let Some(command) = runbook.commands.first() {
println!("next: {command}");
}
}
fn append_system_runbook_history(repo_root: &Path, runbook: &SystemRunbook) -> Result<()> {
let path = repo_root.join(SYSTEM_RUNBOOKS_PATH);
let mut ledger =
read_json_if_exists(&path)?.unwrap_or_else(|| json!({ "runbooks": [], "version": 1 }));
if !ledger.get("runbooks").is_some_and(Value::is_array) {
ledger["runbooks"] = json!([]);
}
if let Some(runbooks) = ledger.get_mut("runbooks").and_then(Value::as_array_mut) {
for existing in runbooks {
existing["active"] = json!(false);
}
}
let mut record = serde_json::to_value(runbook)?;
record["active"] = json!(true);
record["recordedAtUnixMs"] = json!(current_time_millis()?);
ledger
.get_mut("runbooks")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("system runbooks must be an array"))?
.push(record);
write_json(&path, &ledger)
}
fn read_system_runbook_history(repo_root: &Path) -> Result<Vec<Value>> {
Ok(read_json_if_exists(&repo_root.join(SYSTEM_RUNBOOKS_PATH))?
.and_then(|value| value.get("runbooks").and_then(Value::as_array).cloned())
.unwrap_or_default())
}
fn current_time_millis() -> Result<u64> {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis();
u64::try_from(millis).context("system clock timestamp exceeds u64")
}
fn print_system_plan(plan: &SystemPlan) {
println!("Service system: {} ({})", plan.name, plan.status);
println!(
"contract: {} ({})",
plan.detected_protocol,
plan.semantic_kind.as_str()
);
println!("file: {}", plan.system_file);
println!(
"services: {} / modules: {} / dependencies: {}",
plan.services, plan.modules, plan.dependencies
);
if !plan.kinds.is_empty() {
println!("kinds: {}", plan.kinds.join(", "));
}
if plan.issues.is_empty() {
println!("issues: none");
} else {
println!("issues:");
for issue in &plan.issues {
println!(" - {}: {}", issue.code, issue.message);
}
}
if !plan.commands.is_empty() {
println!("commands:");
for command in &plan.commands {
println!(" {command}");
}
}
}
fn print_system_graph(graph: &SystemGraph) {
println!("Service system graph: {}", graph.name);
println!(
"contract: {} ({})",
graph.artifact_protocol,
graph.semantic_kind.as_str()
);
if !graph.environments.is_empty() {
println!("environments: {}", graph.environments.join(", "));
}
println!("services:");
for service in &graph.services {
println!(
" {} [{}] modules={}",
service.name,
service.target,
service.modules.join(", ")
);
}
println!("modules:");
for module in &graph.modules {
println!(
" {} -> {} capabilities={}",
module.name,
module.owner,
module.capabilities.join(", ")
);
}
println!("dependencies:");
for dependency in &graph.dependencies {
println!(
" {} -> {} [{}] {}",
dependency.from,
dependency.to.as_deref().unwrap_or("?"),
dependency.state,
dependency.capability
);
}
if !graph.issues.is_empty() {
println!("issues:");
for issue in &graph.issues {
println!(" - {}: {}", issue.code, issue.message);
}
}
}
fn print_system_v2_graph(graph: &SystemV2Graph) {
println!("Service system graph: {}", graph.system_id);
println!(
"contract: {} ({})",
graph.artifact_protocol,
graph.semantic_kind.as_str()
);
println!("nodes:");
for node in &graph.nodes {
println!(
" {} [{}] owner={}",
node.id,
node.kind,
node.owner.as_deref().unwrap_or("-")
);
}
println!("relationships:");
for relationship in &graph.relationships {
println!(
" {} -> {} [{}] contract={}",
relationship.from,
relationship.to,
relationship.kind,
relationship.contract_id.as_deref().unwrap_or("-")
);
}
}
fn print_system_drift_report(report: &SystemDriftReport, title: &str) {
println!("{title}: {} ({})", report.name, report.status);
println!("system: {}", report.system_file);
println!("repo: {}", report.repo_root);
if report.graph_issues.is_empty() {
println!("graph issues: none");
} else {
println!("graph issues:");
for issue in &report.graph_issues {
println!(" - {}: {}", issue.code, issue.message);
}
}
if report.drifts.is_empty() {
println!("drift: none");
} else {
println!("drift:");
for drift in &report.drifts {
println!(" - {} {}: {}", drift.resource, drift.name, drift.message);
}
}
if !report.applied.is_empty() {
println!("applied:");
for item in &report.applied {
println!(" - {item}");
}
}
if !report.commands.is_empty() {
println!("commands:");
for command in &report.commands {
println!(" {command}");
}
}
}
fn read_or_empty_system(path: &Path) -> Result<ServiceSystem> {
if path.exists() {
return read_system(path);
}
Ok(ServiceSystem {
dependencies: Vec::new(),
environments: Vec::new(),
modules: Vec::new(),
name: "lenso-system".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: Vec::new(),
})
}
fn read_system(path: &Path) -> Result<ServiceSystem> {
let source = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let system: ServiceSystem =
serde_json::from_str(&source).with_context(|| format!("parse {}", path.display()))?;
if system.protocol != SERVICE_SYSTEM_PROTOCOL {
bail!(
"Service system {} uses unsupported protocol `{}`",
path.display(),
system.protocol
);
}
Ok(system)
}
fn write_system(path: &Path, system: &ServiceSystem) -> Result<()> {
let mut contents = serde_json::to_string_pretty(system).context("serialize service system")?;
contents.push('\n');
write_file(path, contents.as_bytes())
}
fn read_json_if_exists(path: &Path) -> Result<Option<Value>> {
if !path.exists() {
return Ok(None);
}
let source = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&source)
.with_context(|| format!("parse {}", path.display()))
.map(Some)
}
fn write_json(path: &Path, value: &Value) -> Result<()> {
let mut contents = serde_json::to_string_pretty(value).context("serialize JSON")?;
contents.push('\n');
write_file(path, contents.as_bytes())
}
fn system_path(system_file: Option<&Path>) -> Result<PathBuf> {
let current_dir = std::env::current_dir().context("resolve current directory")?;
Ok(absolutize_from(
¤t_dir,
system_file.unwrap_or_else(|| Path::new(DEFAULT_SYSTEM_FILE)),
))
}
fn repo_root_path(repo_root: Option<&Path>) -> Result<PathBuf> {
let current_dir = std::env::current_dir().context("resolve current directory")?;
Ok(absolutize_from(
¤t_dir,
repo_root.unwrap_or_else(|| Path::new(".")),
))
}
fn system_read_path(system_file: Option<&Path>) -> Result<PathBuf> {
let path = system_path(system_file)?;
if !path.exists() {
bail!("Service system file does not exist: {}", path.display());
}
Ok(path)
}
fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("write {}", path.display()))
}
fn absolutize_from(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
base.join(path)
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn display_relative(base: &Path, path: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
fn shell_word(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':'))
{
return value.to_owned();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn service_workspace_command(service: &SystemService) -> Option<String> {
let (Some(cwd), Some(lang), Some(command), Some(ready_url)) = (
service.cwd.as_deref(),
service.lang.as_deref(),
service.command.as_deref(),
service.ready_url.as_deref(),
) else {
return None;
};
let mut line = format!(
"lenso service workspace add {} --cwd {} --lang {} --command {} --ready-url {}",
shell_word(&service.name),
shell_word(cwd),
shell_word(lang),
shell_word(command),
shell_word(ready_url)
);
if let Some(manifest) = service.manifest.as_deref() {
line.push_str(&format!(" --manifest {}", shell_word(manifest)));
}
for module in &service.modules {
line.push_str(&format!(" --module {}", shell_word(module)));
}
Some(line)
}
fn module_service_plan(service: &SystemService) -> Option<Value> {
let (Some(command), Some(ready_url)) =
(service.command.as_deref(), service.ready_url.as_deref())
else {
return None;
};
Some(json!({
"autoStart": true,
"command": command,
"cwd": service.cwd.as_deref().unwrap_or("."),
"name": &service.name,
"readyTimeoutMs": 10000,
"readyUrl": ready_url,
}))
}
fn upsert_module_service_plan(file: &mut Value, module_name: &str, service: Value) -> Result<()> {
let modules = file
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("module services modules must be an array"))?;
let module_entry = if let Some(index) = modules
.iter()
.position(|entry| string_field(entry, "moduleName").as_deref() == Some(module_name))
{
&mut modules[index]
} else {
modules.push(json!({ "moduleName": module_name, "services": [] }));
modules.last_mut().expect("pushed module service entry")
};
if !module_entry.get("services").is_some_and(Value::is_array) {
module_entry["services"] = json!([]);
}
let service_name = string_field(&service, "name")
.ok_or_else(|| anyhow::anyhow!("module service name must be a string"))?;
let services = module_entry
.get_mut("services")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("module service services must be an array"))?;
if let Some(existing) = services
.iter_mut()
.find(|entry| string_field(entry, "name").as_deref() == Some(service_name.as_str()))
{
*existing = service;
} else {
services.push(service);
}
Ok(())
}
fn upsert_service_environment(
file: &mut Value,
service: &SystemService,
environment: &str,
) -> Result<()> {
let environments = file
.get_mut("environments")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("service environments must be an array"))?;
let value = json!({
"name": environment,
"releaseTrack": environment,
"serviceName": &service.name,
"target": &service.target,
});
if let Some(existing) = environments.iter_mut().find(|entry| {
string_field(entry, "serviceName").as_deref() == Some(service.name.as_str())
&& string_field(entry, "name").as_deref() == Some(environment)
}) {
*existing = value;
} else {
environments.push(value);
}
environments.sort_by_key(|entry| {
(
string_field(entry, "serviceName").unwrap_or_default(),
string_field(entry, "name").unwrap_or_default(),
)
});
Ok(())
}
fn service_env_key_from_value(value: &Value) -> Option<String> {
Some(service_environment_key(
&string_field(value, "serviceName")?,
&string_field(value, "environment").or_else(|| string_field(value, "name"))?,
))
}
fn service_environment_key(service: &str, environment: &str) -> String {
format!("{service}/{environment}")
}
fn string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_plan_serializes_version_next_actions_and_approval_boundaries() {
let plan = SystemPlan {
approval_boundaries: vec![ApprovalBoundary::production_change(
"apply-system-state",
"lenso system apply --system-file lenso.system.json",
)],
artifact_version: SYSTEM_PLAN_ARTIFACT_VERSION.to_owned(),
commands: vec!["lenso system apply --system-file lenso.system.json".to_owned()],
dependencies: 0,
detected_protocol: "lenso.system.v1".to_owned(),
issues: vec![SystemIssue::new(
"module_not_declared",
"A module is missing.",
"Declare the module and rerun `lenso system plan --check`.",
)],
kinds: Vec::new(),
modules: 0,
name: "support".to_owned(),
next_actions: vec![
"Declare the module and rerun `lenso system plan --check`.".to_owned(),
],
provider_semantics: None,
semantic_kind: ContractSemanticKind::ProviderSystem,
services: 0,
status: "needs_attention".to_owned(),
system_file: "lenso.system.json".to_owned(),
};
let output = serde_json::to_value(plan).unwrap();
assert_eq!(output["artifactVersion"], SYSTEM_PLAN_ARTIFACT_VERSION);
assert_eq!(output["issues"][0]["code"], "module_not_declared");
assert_eq!(
output["issues"][0]["nextAction"],
"Declare the module and rerun `lenso system plan --check`."
);
assert_eq!(output["approvalBoundaries"][0]["required"], true);
assert_eq!(output["approvalBoundaries"][0]["executed"], false);
}
#[test]
fn logically_reordered_v1_systems_produce_identical_machine_graphs() {
let first: ServiceSystem = serde_json::from_value(json!({
"protocol": "lenso.system.v1",
"name": "support",
"environments": ["prod", "local"],
"services": [
{"name": "web", "target": "local", "modules": ["tickets", "auth"]},
{"name": "worker", "target": "local", "modules": []}
],
"modules": [
{"name": "tickets", "capabilities": ["tickets.write", "tickets.read"]},
{"name": "auth", "capabilities": ["auth.read"]}
]
}))
.unwrap();
let second: ServiceSystem = serde_json::from_value(json!({
"name": "support",
"protocol": "lenso.system.v1",
"modules": [
{"capabilities": ["auth.read"], "name": "auth"},
{"capabilities": ["tickets.read", "tickets.write"], "name": "tickets"}
],
"services": [
{"modules": [], "target": "local", "name": "worker"},
{"modules": ["auth", "tickets"], "target": "local", "name": "web"}
],
"environments": ["local", "prod"]
}))
.unwrap();
assert_eq!(
serde_json::to_string(&system_graph(&first)).unwrap(),
serde_json::to_string(&system_graph(&second)).unwrap()
);
}
#[test]
fn mixed_system_v2_graph_exposes_protocol_and_all_explicit_kinds() {
let artifact: Value =
serde_json::from_str(lenso_service::MIXED_SYSTEM_V2_FIXTURE_JSON).unwrap();
let check = check_contract_artifact_value(&artifact).unwrap();
let graph = system_v2_graph(&artifact).unwrap();
assert_eq!(check.detected_protocol, "lenso.system.v2");
assert_eq!(check.semantic_kind, ContractSemanticKind::MixedSystem);
assert_eq!(graph.artifact_protocol, "lenso.system.v2");
for kind in [
"host",
"provider",
"autonomous_service",
"module",
"workload",
"producer",
"consumer",
] {
assert!(graph.nodes.iter().any(|node| node.kind == kind));
}
}
#[test]
fn graph_resolves_module_dependencies_by_capability() {
let system = ServiceSystem {
dependencies: Vec::new(),
environments: vec!["local".to_owned()],
modules: vec![
SystemModule {
capabilities: Vec::new(),
dependencies: vec!["billing.invoice.read".to_owned()],
install_to: Some("service:support".to_owned()),
name: "support-ticket".to_owned(),
},
SystemModule {
capabilities: vec!["billing.invoice.read".to_owned()],
dependencies: Vec::new(),
install_to: Some("service:billing".to_owned()),
name: "invoice".to_owned(),
},
],
name: "support-platform".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: vec![
SystemService {
command: None,
cwd: None,
lang: None,
manifest: None,
modules: vec!["support-ticket".to_owned()],
name: "support".to_owned(),
ready_url: None,
target: "local".to_owned(),
},
SystemService {
command: None,
cwd: None,
lang: None,
manifest: None,
modules: vec!["invoice".to_owned()],
name: "billing".to_owned(),
ready_url: None,
target: "kubernetes".to_owned(),
},
],
};
let graph = system_graph(&system);
assert_eq!(graph.artifact_protocol, "lenso.system.v1");
assert_eq!(graph.semantic_kind, ContractSemanticKind::ProviderSystem);
assert_eq!(graph.dependencies[0].state, "resolved");
assert_eq!(graph.dependencies[0].to.as_deref(), Some("billing"));
assert!(graph.issues.is_empty());
}
#[test]
fn graph_reports_missing_install_targets() {
let system = ServiceSystem {
dependencies: Vec::new(),
environments: Vec::new(),
modules: vec![SystemModule {
capabilities: Vec::new(),
dependencies: Vec::new(),
install_to: Some("service:missing".to_owned()),
name: "support-ticket".to_owned(),
}],
name: "support-platform".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: Vec::new(),
};
let graph = system_graph(&system);
assert_eq!(graph.issues[0].code, "install_target_missing");
}
#[test]
fn graph_checks_explicit_target_capabilities() {
let system = ServiceSystem {
dependencies: vec![SystemDependency {
capability: "billing.invoice.write".to_owned(),
from: "support".to_owned(),
to: Some("billing".to_owned()),
}],
environments: Vec::new(),
modules: vec![SystemModule {
capabilities: vec!["billing.invoice.read".to_owned()],
dependencies: Vec::new(),
install_to: Some("service:billing".to_owned()),
name: "invoice".to_owned(),
}],
name: "support-platform".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: vec![SystemService {
command: None,
cwd: None,
lang: None,
manifest: None,
modules: vec!["invoice".to_owned()],
name: "billing".to_owned(),
ready_url: None,
target: "external".to_owned(),
}],
};
let graph = system_graph(&system);
assert_eq!(graph.dependencies[0].state, "missing_capability");
assert_eq!(graph.issues[0].code, "dependency_missing_capability");
}
#[test]
fn commands_use_service_workspace_when_enough_fields_exist() {
let system = ServiceSystem {
dependencies: Vec::new(),
environments: Vec::new(),
modules: Vec::new(),
name: "support-platform".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: vec![SystemService {
command: Some("pnpm dev".to_owned()),
cwd: Some("services/support".to_owned()),
lang: Some("ts".to_owned()),
manifest: Some("lenso.service.json".to_owned()),
modules: vec!["support-ticket".to_owned()],
name: "support".to_owned(),
ready_url: Some("http://127.0.0.1:4110/lenso/service/v1/status".to_owned()),
target: "local".to_owned(),
}],
};
let commands = system_commands(&system);
assert!(commands[0].contains("lenso service workspace add support"));
assert!(commands[0].contains("--module support-ticket"));
}
#[test]
fn drift_report_reads_host_state() {
let root = test_root("drift-report");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(root.join(".lenso")).unwrap();
let system_path = root.join("lenso.system.json");
write_system(&system_path, &support_system()).unwrap();
write_json(
&root.join(MODULE_INSTALLS_PATH),
&json!({
"modules": [{ "moduleName": "support-ticket", "source": "service" }],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(MODULE_SERVICES_PATH),
&json!({
"modules": [{
"moduleName": "support-ticket",
"services": [{ "name": "support", "command": "pnpm start", "readyUrl": "http://127.0.0.1:4110/status" }]
}],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(SERVICE_ENVIRONMENTS_PATH),
&json!({
"environments": [{ "name": "staging", "serviceName": "support", "target": "operator" }],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(SERVICE_DEPLOYMENTS_PATH),
&json!({
"observations": [{ "environment": "staging", "serviceName": "support" }],
"version": 2
}),
)
.unwrap();
write_json(
&root.join(SERVICE_RELEASES_PATH),
&json!({
"releases": [{ "environment": { "name": "staging" }, "serviceName": "support" }],
"version": 1
}),
)
.unwrap();
let report = system_drift_report(Some(&system_path), Some(&root), Vec::new()).unwrap();
assert_eq!(report.status, "ready");
assert!(report.drifts.is_empty());
fs::remove_dir_all(root).ok();
}
#[test]
fn apply_writes_safe_host_state() {
let root = test_root("apply");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(&root).unwrap();
let system = support_system();
let applied = apply_module_services(&root, &system, false).unwrap();
applied
.into_iter()
.chain(apply_service_environments(&root, &system, false).unwrap())
.for_each(drop);
let services = read_json_if_exists(&root.join(MODULE_SERVICES_PATH))
.unwrap()
.unwrap();
let environments = read_json_if_exists(&root.join(SERVICE_ENVIRONMENTS_PATH))
.unwrap()
.unwrap();
assert_eq!(services["modules"][0]["moduleName"], "support-ticket");
assert_eq!(services["modules"][0]["services"][0]["name"], "support");
assert_eq!(environments["environments"][0]["serviceName"], "support");
assert_eq!(environments["environments"][0]["name"], "staging");
fs::remove_dir_all(root).ok();
}
#[test]
fn system_release_plan_blocks_on_drift() {
let root = test_root("release-blocked");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(&root).unwrap();
let system_path = root.join("lenso.system.json");
write_system(&system_path, &support_system()).unwrap();
let plan = build_system_release_plan(
"release",
&SystemReleasePlanOptions {
allow_drift: false,
environment_name: "staging".to_owned(),
json: false,
output: None,
repo_root: Some(root.clone()),
source_environment: None,
system_file: Some(system_path),
},
)
.unwrap();
assert_eq!(plan.protocol, SYSTEM_RELEASE_PROTOCOL);
assert_eq!(plan.status, "blocked");
assert!(plan.policy.issues.iter().any(|issue| {
issue.code == "service_not_configured" || issue.code == "module_not_installed"
}));
fs::remove_dir_all(root).ok();
}
#[test]
fn system_release_history_records_applied_plan() {
let root = test_root("release-history");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(root.join(".lenso")).unwrap();
let system_path = root.join("lenso.system.json");
write_system(&system_path, &support_system()).unwrap();
write_ready_host_state(&root);
let plan = build_system_release_plan(
"release",
&SystemReleasePlanOptions {
allow_drift: false,
environment_name: "staging".to_owned(),
json: false,
output: None,
repo_root: Some(root.clone()),
source_environment: None,
system_file: Some(system_path),
},
)
.unwrap();
assert_eq!(plan.status, "ready");
append_system_release_history(&root, &plan).unwrap();
let history = read_system_release_history(&root).unwrap();
assert_eq!(history[0]["id"], plan.id);
assert!(has_system_release_for_env(&history, "staging"));
fs::remove_dir_all(root).ok();
}
#[test]
fn system_runbook_generates_steps_from_release_plan() {
let root = test_root("runbook-generate");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(root.join(".lenso")).unwrap();
let system_path = root.join("lenso.system.json");
write_system(&system_path, &support_system()).unwrap();
write_ready_host_state(&root);
let release = build_system_release_plan(
"release",
&SystemReleasePlanOptions {
allow_drift: false,
environment_name: "staging".to_owned(),
json: false,
output: None,
repo_root: Some(root.clone()),
source_environment: None,
system_file: Some(system_path),
},
)
.unwrap();
let runbook = build_system_runbook(&release, Path::new("system-release.json")).unwrap();
assert_eq!(runbook.protocol, SYSTEM_RUNBOOK_PROTOCOL);
assert_eq!(runbook.release_id, release.id);
assert_eq!(runbook.status, "ready");
assert!(runbook.steps.iter().any(|step| {
step.kind == "evidence"
&& step.command.as_deref() == Some("lenso system release check system-release.json")
}));
assert!(
runbook
.steps
.iter()
.any(|step| step.kind == "service_deploy")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn system_runbook_history_records_active_runbook() {
let root = test_root("runbook-history");
fs::remove_dir_all(&root).ok();
fs::create_dir_all(root.join(".lenso")).unwrap();
let release = SystemReleasePlan {
commands: vec![
"lenso service deploy status support --env staging --write-state".to_owned(),
],
created_at_unix_ms: 1,
drift_status: "ready".to_owned(),
drifts: Vec::new(),
environment: "staging".to_owned(),
graph_issues: Vec::new(),
id: "sysrel_staging_1".to_owned(),
kind: "release".to_owned(),
modules: vec!["support-ticket".to_owned()],
policy: SystemReleasePolicy {
issues: Vec::new(),
risk: "safe".to_owned(),
},
protocol: SYSTEM_RELEASE_PROTOCOL.to_owned(),
rollback_available: true,
services: vec![SystemReleaseService {
manifest: None,
modules: vec!["support-ticket".to_owned()],
name: "support".to_owned(),
target: "operator".to_owned(),
}],
source_environment: None,
status: "ready".to_owned(),
system_file: "lenso.system.json".to_owned(),
system_name: "support-platform".to_owned(),
};
let runbook = build_system_runbook(&release, Path::new("system-release.json")).unwrap();
append_system_runbook_history(&root, &runbook).unwrap();
let history = read_system_runbook_history(&root).unwrap();
assert_eq!(history[0]["id"], runbook.id);
assert_eq!(history[0]["active"], true);
fs::remove_dir_all(root).ok();
}
fn support_system() -> ServiceSystem {
ServiceSystem {
dependencies: Vec::new(),
environments: vec!["staging".to_owned()],
modules: vec![SystemModule {
capabilities: vec!["support.ticket.read".to_owned()],
dependencies: Vec::new(),
install_to: Some("service:support".to_owned()),
name: "support-ticket".to_owned(),
}],
name: "support-platform".to_owned(),
protocol: SERVICE_SYSTEM_PROTOCOL.to_owned(),
services: vec![SystemService {
command: Some("pnpm start".to_owned()),
cwd: Some("services/support".to_owned()),
lang: Some("ts".to_owned()),
manifest: Some("http://127.0.0.1:4110/lenso/service/v1/manifest".to_owned()),
modules: vec!["support-ticket".to_owned()],
name: "support".to_owned(),
ready_url: Some("http://127.0.0.1:4110/status".to_owned()),
target: "operator".to_owned(),
}],
}
}
fn test_root(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"lenso-system-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn write_ready_host_state(root: &Path) {
write_json(
&root.join(MODULE_INSTALLS_PATH),
&json!({
"modules": [{ "moduleName": "support-ticket", "source": "service" }],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(MODULE_SERVICES_PATH),
&json!({
"modules": [{
"moduleName": "support-ticket",
"services": [{ "name": "support", "command": "pnpm start", "readyUrl": "http://127.0.0.1:4110/status" }]
}],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(SERVICE_ENVIRONMENTS_PATH),
&json!({
"environments": [{ "name": "staging", "serviceName": "support", "target": "operator" }],
"version": 1
}),
)
.unwrap();
write_json(
&root.join(SERVICE_DEPLOYMENTS_PATH),
&json!({
"observations": [{ "environment": "staging", "serviceName": "support" }],
"version": 2
}),
)
.unwrap();
write_json(
&root.join(SERVICE_RELEASES_PATH),
&json!({
"releases": [{ "environment": { "name": "staging" }, "serviceName": "support" }],
"version": 1
}),
)
.unwrap();
}
}