use crate::dto::{
AdminActionInvocationDto, AdminActionInvokeRequest, AdminActionInvokeResponse,
AdminCapabilityIssueDto, AdminCapabilitySummaryDto, AdminDataDetailResponse,
AdminDataListResponse, AdminDataPageInfo, AdminKubernetesDeploymentObservationDto,
AdminLaunchpadAddonDto, AdminLaunchpadCapabilityPackDto, AdminLaunchpadChangePlanItemDto,
AdminLaunchpadChangePlanResponse, AdminLaunchpadChangePlanStatus,
AdminLaunchpadChecklistItemDto, AdminLaunchpadCompositionActionDto,
AdminLaunchpadCompositionDto, AdminLaunchpadDoctorCheckDto, AdminLaunchpadDoctorResponse,
AdminLaunchpadDoctorStatus, AdminLaunchpadIssueDto, AdminLaunchpadModuleDto,
AdminLaunchpadPackFitDto, AdminLaunchpadProofCheckDto, AdminLaunchpadProofDriftDto,
AdminLaunchpadProofResponse, AdminLaunchpadProofStatus, AdminLaunchpadResponse,
AdminLaunchpadServiceDto, AdminLaunchpadStatus, AdminModuleActivationState,
AdminModuleCompatibilityDto, AdminModuleConsolePackagePlanPackageDto,
AdminModuleConsolePackagePlanStateDto, AdminModuleGovernanceDto,
AdminModuleHostCompatibilityDto, AdminModuleInstallResponse, AdminModuleInstallStateDto,
AdminModuleLinkedSourceInstallStateDto, AdminModuleMetadataDto,
AdminModuleMetadataListResponse, AdminModuleRefreshModuleResultDto,
AdminModuleRefreshModuleStatusDto, AdminModuleRefreshRecordDto, AdminModuleRefreshStatusDto,
AdminModuleRegistrySnapshotCatalogDto, AdminModuleRegistrySnapshotIssueDto,
AdminModuleRegistrySnapshotManifestStatus, AdminModuleRegistrySnapshotModuleDto,
AdminModuleRegistrySnapshotModuleStatus, AdminModuleRegistrySnapshotResponse,
AdminModuleRegistrySnapshotStatus, AdminModuleReleaseDto,
AdminModuleRemoteSourceInstallStateDto, AdminModuleSchema, AdminModuleSourceDiagnosticsDto,
AdminModuleStatus, AdminQueryResponse, AdminRemoteModuleDiagnosticsDto,
AdminSchemaListResponse, AdminSchemaRefreshResponse, AdminServiceDeploymentCheckDto,
AdminServiceDeploymentHostObservationDto, AdminServiceDeploymentObservationDto,
AdminServiceDeploymentOperatorConditionDto, AdminServiceDeploymentOperatorObservationDto,
AdminServiceEnvironmentDto, AdminServiceModuleCompatibilityDto,
AdminServiceModuleCompatibilityState, AdminServiceModuleConfigDto,
AdminServiceModuleDeploymentDto, AdminServiceModuleHealthCheckDto,
AdminServiceModuleLifecycleModuleDto, AdminServiceModuleLifecycleModuleStatus,
AdminServiceModuleLifecycleResponse, AdminServiceModuleLifecycleServiceDto,
AdminServiceModuleLifecycleStatus, AdminServiceModuleManifestStatus,
AdminServiceModuleServiceStatusCheckDto, AdminServiceModuleServiceStatusDto,
AdminServiceModuleServiceStatusState, AdminServiceOperationDto, AdminServiceOperationKindDto,
AdminServiceOperationLinksDto, AdminServiceReleaseRecordDto, AdminServiceSystemDependencyDto,
AdminServiceSystemDriftDto, AdminServiceSystemDriftResponse, AdminServiceSystemDriftStatus,
AdminServiceSystemIssueDto, AdminServiceSystemModuleDto, AdminServiceSystemReleaseRecordDto,
AdminServiceSystemReleaseTrainResponse, AdminServiceSystemReleaseTrainStatus,
AdminServiceSystemResponse, AdminServiceSystemRunbookDto, AdminServiceSystemRunbooksResponse,
AdminServiceSystemRunbooksStatus, AdminServiceSystemServiceDto, AdminServiceSystemStatus,
};
use crate::{
AdminModule, AdminModuleMetadata, AdminModuleMetadataRefreshModuleResult,
AdminModuleMetadataRefreshModuleStatus, AdminModuleMetadataRefreshRecord,
AdminModuleMetadataRefreshStatus, AdminModuleSourceDiagnostics, admin_metadata_refresher,
admin_module_metadata_snapshot, admin_modules, admin_refresher, find_loaded_action_module,
find_loaded_module, find_loaded_query_module, install_admin_module_metadata,
install_admin_modules, record_admin_module_metadata_refresh_error,
record_admin_module_metadata_refresh_success,
};
use axum::Json;
use axum::extract::{Path, Query, State};
use platform_core::{
AdminActionStoryRecord, AppContext, AppError, ErrorCode, RequestContext,
admin_action_story_event_id, insert_admin_action_story_event,
};
use platform_http::{AdminActor, ApiErrorResponse, ErrorResponse, HttpRequestContext};
use platform_module::{
AdminActionConfirmation, AdminActionInputField, AdminActionInputSchema,
AdminDeclarativeComponent, AdminListQuery, AdminSurface, FieldType, ModuleHttpMethod,
ModuleLoadStatus, ModuleManifestLint, ModuleManifestLintSeverity, ModuleSource,
lint_module_manifest_parts, module_capability_references,
};
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
const DEFAULT_LIMIT: i64 = 50;
const MAX_LIMIT: i64 = 200;
const CONSOLE_EXTENSION_REGISTRY_PATH: &str = ".lenso/console/extensions/registry.json";
const CONSOLE_EXTENSION_ROUTE_PREFIX: &str = "/console/extensions";
const HOST_CONSOLE_PACKAGE_API_VERSION: &str = "1";
const HOST_LENSO_VERSION: &str = env!("CARGO_PKG_VERSION");
const HOST_REMOTE_PROTOCOL_VERSION: &str = "1";
const MODULE_INSTALL_LEDGER_PATH: &str = ".lenso/module-installs.json";
const MODULE_SERVICES_PATH: &str = ".lenso/module-services.json";
const SERVICE_RELEASE_LEDGER_PATH: &str = ".lenso/service-releases.json";
const SERVICE_ENVIRONMENTS_PATH: &str = ".lenso/service-environments.json";
const SERVICE_DEPLOYMENTS_PATH: &str = ".lenso/service-deployments.json";
const SERVICE_MODULE_HEALTH_PATH: &str = ".lenso/service-health.json";
const SERVICE_SYSTEM_PATH: &str = "lenso.system.json";
const LAUNCHPAD_PATH: &str = ".lenso/launchpad.json";
const DEV_DOCTOR_PATH: &str = ".lenso/dev-doctor.json";
const APP_PROOF_PATH: &str = ".lenso/app-proof.json";
const APP_CHANGE_PLAN_PATH: &str = ".lenso/app-change-plan.json";
const SYSTEM_RELEASES_PATH: &str = ".lenso/system-releases.json";
const SYSTEM_RUNBOOKS_PATH: &str = ".lenso/system-runbooks.json";
const OFFICIAL_MODULE_CATALOG_REGISTRY_FILE: &str = "builtin:lenso-official-module-catalog";
const OFFICIAL_MODULE_CATALOG_SOURCE: &str =
include_str!("../catalogs/lenso-official-module-catalog.json");
const SUPPORTED_SERVICE_MODULE_FEATURES: &[&str] = &[
"console.package-api.1",
"service.lifecycle",
"service.status",
];
#[derive(Default)]
struct LocalServiceDeploymentState {
current_by_service: HashMap<String, Vec<AdminServiceDeploymentObservationDto>>,
history_by_service: HashMap<String, Vec<AdminServiceDeploymentObservationDto>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct DataListQuery {
pub limit: Option<i64>,
pub cursor: Option<String>,
}
#[utoipa::path(
get,
path = "/admin/data/modules",
operation_id = "admin_data_list_modules",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "All module registry metadata", body = AdminModuleMetadataListResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn list_modules(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleMetadataListResponse>, ApiErrorResponse> {
Ok(Json(metadata_response(admin_module_metadata_snapshot())))
}
#[utoipa::path(
post,
path = "/admin/data/modules/refresh",
operation_id = "admin_data_refresh_modules",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Module registry metadata snapshot after refresh", body = AdminModuleMetadataListResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 502, description = "Module registry refresh is unavailable", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn refresh_modules(
_admin: AdminActor,
HttpRequestContext(request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleMetadataListResponse>, ApiErrorResponse> {
let refresher = admin_metadata_refresher().ok_or_else(|| {
ApiErrorResponse::with_context(
AppError::new(
ErrorCode::ExternalDependency,
"module registry refresh is unavailable",
)
.retryable(),
&request_ctx,
)
})?;
let started_at = crate::current_timestamp();
let started = Instant::now();
match refresher.refresh_admin_module_metadata().await {
Ok(metadata) => Ok(Json(metadata_response(
record_admin_module_metadata_refresh_success(metadata, started_at, started),
))),
Err(error) => Ok(Json(metadata_response(
record_admin_module_metadata_refresh_error(error.public_message, started_at, started),
))),
}
}
#[utoipa::path(
get,
path = "/admin/data/module-registry/snapshot",
operation_id = "admin_data_module_registry_snapshot",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Legacy alias for available catalog services", body = AdminModuleRegistrySnapshotResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn module_registry_snapshot(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleRegistrySnapshotResponse>, ApiErrorResponse> {
Ok(Json(available_modules_response()))
}
#[utoipa::path(
get,
path = "/admin/data/available-modules",
operation_id = "admin_data_available_modules",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Available catalog services and linked modules", body = AdminModuleRegistrySnapshotResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn available_modules(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleRegistrySnapshotResponse>, ApiErrorResponse> {
Ok(Json(available_modules_response()))
}
#[utoipa::path(
get,
path = "/admin/data/service-modules",
operation_id = "admin_data_service_modules",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Service provider lifecycle state", body = AdminServiceModuleLifecycleResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn service_modules(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminServiceModuleLifecycleResponse>, ApiErrorResponse> {
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
PathBuf::from(".env"),
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
);
Ok(Json(
service_module_lifecycle_response(metadata, install_state).await,
))
}
#[utoipa::path(
get,
path = "/admin/data/service-system",
operation_id = "admin_data_service_system",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Service system graph", body = AdminServiceSystemResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn service_system(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminServiceSystemResponse>, ApiErrorResponse> {
Ok(Json(service_system_response(FsPath::new(
SERVICE_SYSTEM_PATH,
))))
}
#[utoipa::path(
get,
path = "/admin/data/service-system/drift",
operation_id = "admin_data_service_system_drift",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Service system drift against host-local state", body = AdminServiceSystemDriftResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn service_system_drift(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminServiceSystemDriftResponse>, ApiErrorResponse> {
Ok(Json(service_system_drift_response(FsPath::new(
SERVICE_SYSTEM_PATH,
))))
}
#[utoipa::path(
get,
path = "/admin/data/service-system/release-train",
operation_id = "admin_data_service_system_release_train",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Applied system release train history", body = AdminServiceSystemReleaseTrainResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn service_system_release_train(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminServiceSystemReleaseTrainResponse>, ApiErrorResponse> {
Ok(Json(service_system_release_train_response(FsPath::new(
SYSTEM_RELEASES_PATH,
))))
}
#[utoipa::path(
get,
path = "/admin/data/service-system/runbooks",
operation_id = "admin_data_service_system_runbooks",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "System runbook history", body = AdminServiceSystemRunbooksResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn service_system_runbooks(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminServiceSystemRunbooksResponse>, ApiErrorResponse> {
Ok(Json(service_system_runbooks_response(FsPath::new(
SYSTEM_RUNBOOKS_PATH,
))))
}
#[utoipa::path(
get,
path = "/admin/data/launchpad",
operation_id = "admin_data_launchpad",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Launchpad first-run state", body = AdminLaunchpadResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn launchpad(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminLaunchpadResponse>, ApiErrorResponse> {
Ok(Json(launchpad_response(FsPath::new(LAUNCHPAD_PATH))))
}
#[utoipa::path(
get,
path = "/admin/data/launchpad/doctor",
operation_id = "admin_data_launchpad_doctor",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Launchpad developer doctor state", body = AdminLaunchpadDoctorResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn launchpad_doctor(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminLaunchpadDoctorResponse>, ApiErrorResponse> {
Ok(Json(launchpad_doctor_response(FsPath::new(
DEV_DOCTOR_PATH,
))))
}
#[utoipa::path(
get,
path = "/admin/data/launchpad/proof",
operation_id = "admin_data_launchpad_proof",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Launchpad App Proof state", body = AdminLaunchpadProofResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn launchpad_proof(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminLaunchpadProofResponse>, ApiErrorResponse> {
Ok(Json(launchpad_proof_response(FsPath::new(APP_PROOF_PATH))))
}
#[utoipa::path(
get,
path = "/admin/data/launchpad/change-plan",
operation_id = "admin_data_launchpad_change_plan",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Launchpad app change plan state", body = AdminLaunchpadChangePlanResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn launchpad_change_plan(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminLaunchpadChangePlanResponse>, ApiErrorResponse> {
Ok(Json(launchpad_change_plan_response(FsPath::new(
APP_CHANGE_PLAN_PATH,
))))
}
#[utoipa::path(
post,
path = "/admin/data/available-modules/{module}/install",
operation_id = "admin_data_install_available_module",
tag = "admin-data",
params(
("module" = String, Path, description = "Available catalog entry name"),
("authorization" = String, Header, description = "Development service bearer token"),
),
responses(
(status = 200, description = "Catalog install state written to host-local files", body = AdminModuleInstallResponse, content_type = "application/json"),
(status = 400, description = "Catalog entry cannot be installed", body = ErrorResponse, content_type = "application/problem+json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown available module", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn install_available_module(
_admin: AdminActor,
Path(module): Path<String>,
HttpRequestContext(request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleInstallResponse>, ApiErrorResponse> {
install_available_module_response(module, &request_ctx)
.await
.map(Json)
}
#[utoipa::path(
delete,
path = "/admin/data/available-modules/{module}/install",
operation_id = "admin_data_uninstall_available_module",
tag = "admin-data",
params(
("module" = String, Path, description = "Available module name"),
("authorization" = String, Header, description = "Development service bearer token"),
),
responses(
(status = 200, description = "Module install state removed from host-local files", body = AdminModuleInstallResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown available module", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn uninstall_available_module(
_admin: AdminActor,
Path(module): Path<String>,
HttpRequestContext(request_ctx): HttpRequestContext,
) -> Result<Json<AdminModuleInstallResponse>, ApiErrorResponse> {
uninstall_available_module_response(module, &request_ctx).map(Json)
}
fn available_modules_response() -> AdminModuleRegistrySnapshotResponse {
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
PathBuf::from(".env"),
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
);
if let Some(response) =
module_catalog_file_response(PathBuf::from(".lenso/module-catalog.json"), &install_state)
{
return response;
}
if metadata
.iter()
.any(|module| matches!(module.source, ModuleSource::Remote))
{
return module_registry_snapshot_response(metadata, &install_state);
}
module_catalog_source_response(
OFFICIAL_MODULE_CATALOG_REGISTRY_FILE.to_owned(),
OFFICIAL_MODULE_CATALOG_SOURCE,
&install_state,
)
}
fn service_system_response(path: &FsPath) -> AdminServiceSystemResponse {
if !path.exists() {
return AdminServiceSystemResponse {
dependencies: Vec::new(),
environments: Vec::new(),
issues: Vec::new(),
modules: Vec::new(),
name: None,
services: Vec::new(),
status: AdminServiceSystemStatus::Empty,
system_file: path.to_string_lossy().into_owned(),
version: 1,
};
}
let source = match fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
return service_system_error_response(path, "read_error", error.to_string());
}
};
let system = match serde_json::from_str::<lenso_service::ServiceSystem>(&source) {
Ok(system) => system,
Err(error) => {
return service_system_error_response(path, "parse_error", error.to_string());
}
};
if system.protocol != lenso_service::SERVICE_SYSTEM_PROTOCOL {
return service_system_error_response(
path,
"unsupported_protocol",
format!(
"protocol must be `{}`",
lenso_service::SERVICE_SYSTEM_PROTOCOL
),
);
}
let graph = lenso_service::service_system_graph(&system);
let issues = graph
.issues
.into_iter()
.map(|issue| AdminServiceSystemIssueDto {
code: issue.code,
message: issue.message,
})
.collect::<Vec<_>>();
AdminServiceSystemResponse {
dependencies: graph
.dependencies
.into_iter()
.map(|dependency| AdminServiceSystemDependencyDto {
capability: dependency.capability,
from: dependency.from,
state: dependency.state,
to: dependency.to,
})
.collect(),
environments: graph.environments,
modules: graph
.modules
.into_iter()
.map(|module| AdminServiceSystemModuleDto {
capabilities: module.capabilities,
dependencies: module.dependencies,
name: module.name,
owner: module.owner,
})
.collect(),
name: Some(graph.name),
services: graph
.services
.into_iter()
.map(|service| AdminServiceSystemServiceDto {
modules: service.modules,
name: service.name,
target: service.target,
})
.collect(),
status: if issues.is_empty() {
AdminServiceSystemStatus::Ready
} else {
AdminServiceSystemStatus::NeedsAttention
},
system_file: path.to_string_lossy().into_owned(),
version: 1,
issues,
}
}
fn service_system_error_response(
path: &FsPath,
code: impl Into<String>,
message: impl Into<String>,
) -> AdminServiceSystemResponse {
AdminServiceSystemResponse {
dependencies: Vec::new(),
environments: Vec::new(),
issues: vec![AdminServiceSystemIssueDto {
code: code.into(),
message: message.into(),
}],
modules: Vec::new(),
name: None,
services: Vec::new(),
status: AdminServiceSystemStatus::NeedsAttention,
system_file: path.to_string_lossy().into_owned(),
version: 1,
}
}
fn service_system_drift_response(path: &FsPath) -> AdminServiceSystemDriftResponse {
service_system_drift_response_from_paths(
path,
FsPath::new(MODULE_INSTALL_LEDGER_PATH),
FsPath::new(MODULE_SERVICES_PATH),
FsPath::new(SERVICE_ENVIRONMENTS_PATH),
FsPath::new(SERVICE_DEPLOYMENTS_PATH),
FsPath::new(SERVICE_RELEASE_LEDGER_PATH),
)
}
fn service_system_drift_response_from_paths(
path: &FsPath,
module_installs_path: &FsPath,
module_services_path: &FsPath,
service_environments_path: &FsPath,
service_deployments_path: &FsPath,
service_releases_path: &FsPath,
) -> AdminServiceSystemDriftResponse {
let system = service_system_response(path);
if matches!(system.status, AdminServiceSystemStatus::Empty) {
return AdminServiceSystemDriftResponse {
commands: Vec::new(),
drifts: Vec::new(),
graph_issues: Vec::new(),
status: AdminServiceSystemDriftStatus::Empty,
system_file: system.system_file,
version: 1,
};
}
let installed_modules = local_installed_modules(module_installs_path);
let configured_services = local_configured_services(module_services_path);
let environments = local_service_environments(service_environments_path);
let deployments = local_service_deployments(service_deployments_path);
let releases = local_service_release_history(service_releases_path);
let mut drifts = Vec::new();
for service in &system.services {
if !configured_services.contains(&service.name) {
drifts.push(AdminServiceSystemDriftDto {
code: "service_not_configured".to_owned(),
command: Some("lenso system apply".to_owned()),
message: format!("Service `{}` is declared but not configured.", service.name),
name: service.name.clone(),
resource: "service".to_owned(),
severity: "warning".to_owned(),
});
}
if matches!(service.target.as_str(), "kubernetes" | "operator") {
let service_envs = environments.get(&service.name).cloned().unwrap_or_default();
let service_deployments = deployments
.current_by_service
.get(&service.name)
.cloned()
.unwrap_or_default();
let service_releases = releases.get(&service.name).cloned().unwrap_or_default();
for environment in &system.environments {
if !service_envs.iter().any(|state| state.name == *environment) {
drifts.push(AdminServiceSystemDriftDto {
code: "service_env_missing".to_owned(),
command: Some("lenso system apply".to_owned()),
message: format!(
"Service `{}` has no `{environment}` environment state.",
service.name
),
name: service_environment_key(&service.name, environment),
resource: "environment".to_owned(),
severity: "warning".to_owned(),
});
continue;
}
if !service_deployments
.iter()
.any(|state| state.environment == *environment)
{
drifts.push(AdminServiceSystemDriftDto {
code: "deployment_state_missing".to_owned(),
command: Some(format!(
"lenso service deploy status {} --env {} --source {} --write-state",
service.name, environment, service.target
)),
message: format!(
"Service `{}` has `{environment}` env state but no deployment observation.",
service.name
),
name: service_environment_key(&service.name, environment),
resource: "deployment".to_owned(),
severity: "info".to_owned(),
});
}
if !service_releases
.iter()
.any(|release| release.environment.as_deref() == Some(environment.as_str()))
{
drifts.push(AdminServiceSystemDriftDto {
code: "release_state_missing".to_owned(),
command: Some(format!(
"lenso service release plan {} <manifest-or-package> --env {} --output release-plan.json",
service.name, environment
)),
message: format!(
"Service `{}` has no `{environment}` release record.",
service.name
),
name: service_environment_key(&service.name, environment),
resource: "release".to_owned(),
severity: "info".to_owned(),
});
}
}
}
}
for module in &system.modules {
if !installed_modules.contains(&module.name) {
drifts.push(AdminServiceSystemDriftDto {
code: "module_not_installed".to_owned(),
command: Some(format!("lenso module install {}", module.name)),
message: format!("Module `{}` is declared but not installed.", module.name),
name: module.name.clone(),
resource: "module".to_owned(),
severity: "warning".to_owned(),
});
}
}
let mut commands = drifts
.iter()
.filter_map(|drift| drift.command.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
commands.sort();
let status = if !system.issues.is_empty() {
AdminServiceSystemDriftStatus::NeedsAttention
} else if !drifts.is_empty() {
AdminServiceSystemDriftStatus::Drifted
} else {
AdminServiceSystemDriftStatus::Ready
};
AdminServiceSystemDriftResponse {
commands,
drifts,
graph_issues: system.issues,
status,
system_file: system.system_file,
version: 1,
}
}
fn local_installed_modules(path: impl AsRef<FsPath>) -> HashSet<String> {
let Ok(source) = fs::read_to_string(path) else {
return HashSet::new();
};
let Ok(ledger) = serde_json::from_str::<LocalModuleInstallLedger>(&source) else {
return HashSet::new();
};
ledger
.modules
.into_iter()
.map(|module| module.module_name)
.collect()
}
fn local_configured_services(path: impl AsRef<FsPath>) -> HashSet<String> {
local_module_services(path)
.into_iter()
.flat_map(|(module_name, services)| {
std::iter::once(module_name).chain(services.into_iter().map(|service| service.name))
})
.collect()
}
fn service_environment_key(service: &str, environment: &str) -> String {
format!("{service}/{environment}")
}
fn service_system_release_train_response(path: &FsPath) -> AdminServiceSystemReleaseTrainResponse {
let Ok(source) = fs::read_to_string(path) else {
return AdminServiceSystemReleaseTrainResponse {
commands: vec![
"lenso system release plan --env staging --output system-release.json".to_owned(),
],
releases: Vec::new(),
status: AdminServiceSystemReleaseTrainStatus::Empty,
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminServiceSystemReleaseTrainResponse {
commands: vec!["fix .lenso/system-releases.json".to_owned()],
releases: Vec::new(),
status: AdminServiceSystemReleaseTrainStatus::NeedsAttention,
version: 1,
};
};
let mut releases = file
.get("releases")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(system_release_record_from_value)
.collect::<Vec<_>>();
releases.sort_by(|left, right| {
right
.applied_at_unix_ms
.unwrap_or(0)
.cmp(&left.applied_at_unix_ms.unwrap_or(0))
});
releases.truncate(10);
let status = if releases.is_empty() {
AdminServiceSystemReleaseTrainStatus::Empty
} else if releases.iter().any(|release| release.status == "blocked") {
AdminServiceSystemReleaseTrainStatus::NeedsAttention
} else {
AdminServiceSystemReleaseTrainStatus::Ready
};
AdminServiceSystemReleaseTrainResponse {
commands: vec![
"lenso system release history".to_owned(),
"lenso system release promote --from staging --to prod --output system-release-prod.json"
.to_owned(),
],
releases,
status,
version: 1,
}
}
fn system_release_record_from_value(value: &Value) -> Option<AdminServiceSystemReleaseRecordDto> {
Some(AdminServiceSystemReleaseRecordDto {
applied_at_unix_ms: value.get("appliedAtUnixMs").and_then(Value::as_u64),
environment: value.get("environment")?.as_str()?.to_owned(),
id: value.get("id")?.as_str()?.to_owned(),
kind: value
.get("kind")
.and_then(Value::as_str)
.unwrap_or("release")
.to_owned(),
modules: value
.get("modules")
.and_then(Value::as_array)
.map_or(0, Vec::len),
policy_risk: value
.pointer("/policy/risk")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
rollback_available: value
.get("rollbackAvailable")
.and_then(Value::as_bool)
.unwrap_or(false),
services: value
.get("services")
.and_then(Value::as_array)
.map_or(0, Vec::len),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
system_name: value
.get("systemName")
.and_then(Value::as_str)
.unwrap_or("system")
.to_owned(),
})
}
fn service_system_runbooks_response(path: &FsPath) -> AdminServiceSystemRunbooksResponse {
let Ok(source) = fs::read_to_string(path) else {
return AdminServiceSystemRunbooksResponse {
commands: vec![
"lenso system runbook generate system-release.json --output system-runbook.json"
.to_owned(),
],
runbooks: Vec::new(),
status: AdminServiceSystemRunbooksStatus::Empty,
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminServiceSystemRunbooksResponse {
commands: vec!["fix .lenso/system-runbooks.json".to_owned()],
runbooks: Vec::new(),
status: AdminServiceSystemRunbooksStatus::NeedsAttention,
version: 1,
};
};
let mut runbooks = file
.get("runbooks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(system_runbook_record_from_value)
.collect::<Vec<_>>();
runbooks.sort_by(|left, right| {
right
.recorded_at_unix_ms
.unwrap_or(0)
.cmp(&left.recorded_at_unix_ms.unwrap_or(0))
});
runbooks.truncate(10);
let status = if runbooks.is_empty() {
AdminServiceSystemRunbooksStatus::Empty
} else if runbooks
.iter()
.any(|runbook| matches!(runbook.status.as_str(), "blocked" | "failed"))
{
AdminServiceSystemRunbooksStatus::NeedsAttention
} else {
AdminServiceSystemRunbooksStatus::Ready
};
AdminServiceSystemRunbooksResponse {
commands: vec![
"lenso system runbook history".to_owned(),
"lenso system runbook doctor".to_owned(),
],
runbooks,
status,
version: 1,
}
}
fn system_runbook_record_from_value(value: &Value) -> Option<AdminServiceSystemRunbookDto> {
let steps = value
.get("steps")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let current_step = steps
.iter()
.find(|step| {
step.get("status")
.and_then(Value::as_str)
.is_none_or(|status| status != "done")
})
.and_then(|step| step.get("title").and_then(Value::as_str))
.map(str::to_owned);
Some(AdminServiceSystemRunbookDto {
active: value
.get("active")
.and_then(Value::as_bool)
.unwrap_or(false),
current_step,
environment: value.get("environment")?.as_str()?.to_owned(),
id: value.get("id")?.as_str()?.to_owned(),
recorded_at_unix_ms: value.get("recordedAtUnixMs").and_then(Value::as_u64),
release_id: value.get("releaseId")?.as_str()?.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
steps: steps.len(),
system_name: value.get("systemName")?.as_str()?.to_owned(),
})
}
fn launchpad_response(path: &FsPath) -> AdminLaunchpadResponse {
let launchpad_file = path.to_string_lossy().to_string();
let Ok(source) = fs::read_to_string(path) else {
return AdminLaunchpadResponse {
addons: Vec::new(),
blueprint: None,
checklist: Vec::new(),
commands: vec!["lenso app create support-desk --blueprint support-desk".to_owned()],
issues: Vec::new(),
launchpad_file,
modules: Vec::new(),
next_command: Some("lenso app create support-desk --blueprint support-desk".to_owned()),
project_name: None,
services: Vec::new(),
status: AdminLaunchpadStatus::Empty,
summary: None,
supported_addons: Vec::new(),
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminLaunchpadResponse {
addons: Vec::new(),
blueprint: None,
checklist: Vec::new(),
commands: vec!["fix .lenso/launchpad.json".to_owned()],
issues: vec![AdminLaunchpadIssueDto {
code: "launchpad_parse_error".to_owned(),
command: Some("lenso dev status".to_owned()),
message: "Launchpad state could not be parsed.".to_owned(),
}],
launchpad_file,
modules: Vec::new(),
next_command: Some("fix .lenso/launchpad.json".to_owned()),
project_name: None,
services: Vec::new(),
status: AdminLaunchpadStatus::NeedsAttention,
summary: None,
supported_addons: Vec::new(),
version: 1,
};
};
let services = file
.get("services")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_service_from_value)
.collect::<Vec<_>>();
let modules = file
.get("modules")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_module_from_value)
.collect::<Vec<_>>();
let checklist = file
.get("checklist")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_checklist_item_from_value)
.collect::<Vec<_>>();
let addons = file
.get("addons")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_addon_from_value)
.collect::<Vec<_>>();
let mut commands = launchpad_commands_from_value(file.get("commands"));
if commands.is_empty() {
commands.push("lenso dev up".to_owned());
}
let next_command = checklist
.iter()
.find(|item| item.status == "next")
.and_then(|item| item.next_command.clone())
.or_else(|| commands.first().cloned());
let has_attention = checklist.iter().any(|item| {
matches!(
item.status.as_str(),
"blocked" | "needs_attention" | "failed"
)
});
let status = if services.is_empty() && modules.is_empty() {
AdminLaunchpadStatus::Empty
} else if has_attention {
AdminLaunchpadStatus::NeedsAttention
} else {
AdminLaunchpadStatus::Ready
};
AdminLaunchpadResponse {
addons,
blueprint: file
.get("blueprint")
.and_then(Value::as_str)
.map(str::to_owned),
checklist,
commands,
issues: Vec::new(),
launchpad_file,
modules,
next_command,
project_name: file
.get("projectName")
.and_then(Value::as_str)
.map(str::to_owned),
services,
status,
summary: file
.get("summary")
.and_then(Value::as_str)
.map(str::to_owned),
supported_addons: json_string_list(&file, "supportedAddons"),
version: 1,
}
}
fn launchpad_service_from_value(value: &Value) -> Option<AdminLaunchpadServiceDto> {
Some(AdminLaunchpadServiceDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
cwd: value.get("cwd").and_then(Value::as_str).map(str::to_owned),
language: value
.get("language")
.and_then(Value::as_str)
.map(str::to_owned),
modules: json_string_list(value, "modules"),
name: value.get("name")?.as_str()?.to_owned(),
ready_url: value
.get("readyUrl")
.and_then(Value::as_str)
.map(str::to_owned),
role: value.get("role").and_then(Value::as_str).map(str::to_owned),
})
}
fn launchpad_module_from_value(value: &Value) -> Option<AdminLaunchpadModuleDto> {
Some(AdminLaunchpadModuleDto {
capability: value
.get("capability")
.and_then(Value::as_str)
.map(str::to_owned),
name: value.get("name")?.as_str()?.to_owned(),
owner_service: value
.get("ownerService")
.and_then(Value::as_str)
.map(str::to_owned),
})
}
fn launchpad_checklist_item_from_value(value: &Value) -> Option<AdminLaunchpadChecklistItemDto> {
Some(AdminLaunchpadChecklistItemDto {
id: value.get("id")?.as_str()?.to_owned(),
label: value.get("label")?.as_str()?.to_owned(),
next_command: value
.get("nextCommand")
.and_then(Value::as_str)
.map(str::to_owned),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending")
.to_owned(),
})
}
fn launchpad_addon_from_value(value: &Value) -> Option<AdminLaunchpadAddonDto> {
Some(AdminLaunchpadAddonDto {
label: value
.get("label")
.and_then(Value::as_str)
.unwrap_or_else(|| value.get("name").and_then(Value::as_str).unwrap_or("addon"))
.to_owned(),
modules: json_string_list(value, "modules"),
name: value.get("name")?.as_str()?.to_owned(),
services: json_string_list(value, "services"),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("configured")
.to_owned(),
})
}
fn launchpad_commands_from_value(value: Option<&Value>) -> Vec<String> {
let Some(commands) = value.and_then(Value::as_object) else {
return Vec::new();
};
["devUp", "devStatus", "agentContext", "console"]
.iter()
.filter_map(|key| {
commands
.get(*key)
.and_then(Value::as_str)
.map(str::to_owned)
})
.collect()
}
fn launchpad_doctor_response(path: &FsPath) -> AdminLaunchpadDoctorResponse {
let doctor_file = path.to_string_lossy().to_string();
let Ok(source) = fs::read_to_string(path) else {
return AdminLaunchpadDoctorResponse {
checked_at_unix_ms: None,
checks: Vec::new(),
doctor_file,
live: false,
next_command: Some("lenso dev doctor --write-state".to_owned()),
status: AdminLaunchpadDoctorStatus::Empty,
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminLaunchpadDoctorResponse {
checked_at_unix_ms: None,
checks: vec![AdminLaunchpadDoctorCheckDto {
command: Some("fix .lenso/dev-doctor.json".to_owned()),
id: "dev-doctor-parse".to_owned(),
label: "Dev doctor state".to_owned(),
message: "Dev doctor state could not be parsed.".to_owned(),
status: "failed".to_owned(),
}],
doctor_file,
live: false,
next_command: Some("fix .lenso/dev-doctor.json".to_owned()),
status: AdminLaunchpadDoctorStatus::Failed,
version: 1,
};
};
let checks = file
.get("checks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_doctor_check_from_value)
.collect::<Vec<_>>();
let status = match file.get("status").and_then(Value::as_str) {
Some("ready") => AdminLaunchpadDoctorStatus::Ready,
Some("needs_attention") => AdminLaunchpadDoctorStatus::NeedsAttention,
Some("failed") => AdminLaunchpadDoctorStatus::Failed,
_ if checks.is_empty() => AdminLaunchpadDoctorStatus::Empty,
_ if checks
.iter()
.any(|check| check.status == "failed" || check.status == "needs_attention") =>
{
AdminLaunchpadDoctorStatus::NeedsAttention
}
_ => AdminLaunchpadDoctorStatus::Ready,
};
let next_command = file
.get("nextCommand")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| checks.iter().find_map(|check| check.command.clone()));
AdminLaunchpadDoctorResponse {
checked_at_unix_ms: file.get("checkedAtUnixMs").and_then(Value::as_u64),
checks,
doctor_file,
live: file.get("live").and_then(Value::as_bool).unwrap_or(false),
next_command,
status,
version: 1,
}
}
fn launchpad_doctor_check_from_value(value: &Value) -> Option<AdminLaunchpadDoctorCheckDto> {
Some(AdminLaunchpadDoctorCheckDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
id: value.get("id")?.as_str()?.to_owned(),
label: value.get("label")?.as_str()?.to_owned(),
message: value
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
})
}
fn launchpad_proof_response(path: &FsPath) -> AdminLaunchpadProofResponse {
let proof_file = path.to_string_lossy().to_string();
let Ok(source) = fs::read_to_string(path) else {
return AdminLaunchpadProofResponse {
addons: Vec::new(),
blueprint: None,
checked_at_unix_ms: None,
checks: Vec::new(),
drifts: Vec::new(),
next_command: Some("lenso app verify --write-proof".to_owned()),
project_name: None,
proof_file,
status: AdminLaunchpadProofStatus::Empty,
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminLaunchpadProofResponse {
addons: Vec::new(),
blueprint: None,
checked_at_unix_ms: None,
checks: vec![AdminLaunchpadProofCheckDto {
command: Some("fix .lenso/app-proof.json".to_owned()),
id: "app-proof-parse".to_owned(),
label: "App Proof state".to_owned(),
message: "App Proof state could not be parsed.".to_owned(),
status: "failed".to_owned(),
}],
drifts: Vec::new(),
next_command: Some("fix .lenso/app-proof.json".to_owned()),
project_name: None,
proof_file,
status: AdminLaunchpadProofStatus::Failed,
version: 1,
};
};
let checks = file
.get("checks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_proof_check_from_value)
.collect::<Vec<_>>();
let drifts = file
.get("drifts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_proof_drift_from_value)
.collect::<Vec<_>>();
let status = launchpad_proof_status_from_value(
file.get("status").and_then(Value::as_str),
&checks,
&drifts,
);
let next_command = file
.get("nextCommand")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| drifts.iter().find_map(|drift| drift.command.clone()))
.or_else(|| checks.iter().find_map(|check| check.command.clone()));
AdminLaunchpadProofResponse {
addons: json_string_list(&file, "addons"),
blueprint: file
.get("blueprint")
.and_then(Value::as_str)
.map(str::to_owned),
checked_at_unix_ms: file.get("checkedAtUnixMs").and_then(Value::as_u64),
checks,
drifts,
next_command,
project_name: file
.get("projectName")
.and_then(Value::as_str)
.map(str::to_owned),
proof_file,
status,
version: 1,
}
}
fn launchpad_proof_check_from_value(value: &Value) -> Option<AdminLaunchpadProofCheckDto> {
Some(AdminLaunchpadProofCheckDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
id: value.get("id")?.as_str()?.to_owned(),
label: value.get("label")?.as_str()?.to_owned(),
message: value
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned(),
})
}
fn launchpad_proof_drift_from_value(value: &Value) -> Option<AdminLaunchpadProofDriftDto> {
Some(AdminLaunchpadProofDriftDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
message: value
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
name: value.get("name")?.as_str()?.to_owned(),
resource: value.get("resource")?.as_str()?.to_owned(),
})
}
fn launchpad_proof_status_from_value(
status: Option<&str>,
checks: &[AdminLaunchpadProofCheckDto],
drifts: &[AdminLaunchpadProofDriftDto],
) -> AdminLaunchpadProofStatus {
match status {
Some("ready") => AdminLaunchpadProofStatus::Ready,
Some("drifted") => AdminLaunchpadProofStatus::Drifted,
Some("needs_attention") => AdminLaunchpadProofStatus::NeedsAttention,
Some("failed") => AdminLaunchpadProofStatus::Failed,
Some("empty") => AdminLaunchpadProofStatus::Empty,
_ if !drifts.is_empty() => AdminLaunchpadProofStatus::Drifted,
_ if checks.iter().any(|check| check.status == "failed") => {
AdminLaunchpadProofStatus::Failed
}
_ if checks.iter().any(|check| check.status == "needs_attention") => {
AdminLaunchpadProofStatus::NeedsAttention
}
_ if checks.is_empty() => AdminLaunchpadProofStatus::Empty,
_ => AdminLaunchpadProofStatus::Ready,
}
}
fn launchpad_change_plan_response(path: &FsPath) -> AdminLaunchpadChangePlanResponse {
let plan_file = path.to_string_lossy().to_string();
let Ok(source) = fs::read_to_string(path) else {
return AdminLaunchpadChangePlanResponse {
addons: Vec::new(),
blocked: Vec::new(),
blueprint: None,
changes: Vec::new(),
composition: None,
generated_at_unix_ms: None,
issues: Vec::new(),
next_command: Some("lenso app plan --write-plan".to_owned()),
plan_file,
project_name: None,
proof_status: None,
status: AdminLaunchpadChangePlanStatus::Empty,
version: 1,
};
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return AdminLaunchpadChangePlanResponse {
addons: Vec::new(),
blocked: Vec::new(),
blueprint: None,
changes: Vec::new(),
composition: None,
generated_at_unix_ms: None,
issues: vec![AdminLaunchpadIssueDto {
code: "app_change_plan_parse_error".to_owned(),
command: Some("lenso app plan --write-plan".to_owned()),
message: "App change plan could not be parsed.".to_owned(),
}],
next_command: Some("lenso app plan --write-plan".to_owned()),
plan_file,
project_name: None,
proof_status: None,
status: AdminLaunchpadChangePlanStatus::Failed,
version: 1,
};
};
let changes = file
.get("changes")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_change_plan_item_from_value)
.collect::<Vec<_>>();
let blocked = file
.get("blocked")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_change_plan_item_from_value)
.collect::<Vec<_>>();
let status = launchpad_change_plan_status_from_value(
file.get("status").and_then(Value::as_str),
&changes,
&blocked,
);
let next_command = file
.get("nextCommand")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| blocked.iter().find_map(|item| item.command.clone()))
.or_else(|| changes.iter().find_map(|item| item.command.clone()));
AdminLaunchpadChangePlanResponse {
addons: json_string_list(&file, "addons"),
blocked,
blueprint: file
.get("blueprint")
.and_then(Value::as_str)
.map(str::to_owned),
changes,
composition: launchpad_composition_from_value(file.get("composition")),
generated_at_unix_ms: file.get("generatedAtUnixMs").and_then(Value::as_u64),
issues: Vec::new(),
next_command,
plan_file,
project_name: file
.get("projectName")
.and_then(Value::as_str)
.map(str::to_owned),
proof_status: file
.get("proofStatus")
.and_then(Value::as_str)
.map(str::to_owned),
status,
version: 1,
}
}
fn launchpad_composition_from_value(value: Option<&Value>) -> Option<AdminLaunchpadCompositionDto> {
let value = value?;
Some(AdminLaunchpadCompositionDto {
agent_actions: value
.get("agentActions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_composition_action_from_value)
.collect(),
applied_addons: json_string_list(value, "appliedAddons"),
applied_packs: json_string_list(value, "appliedPacks"),
capability_packs: value
.get("capabilityPacks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_capability_pack_from_value)
.collect(),
intent: value
.get("intent")
.and_then(Value::as_str)
.map(str::to_owned),
pending_addons: json_string_list(value, "pendingAddons"),
pending_packs: json_string_list(value, "pendingPacks"),
pack_fit: value
.get("packFit")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_pack_fit_from_value)
.collect(),
protocol: value
.get("protocol")
.and_then(Value::as_str)
.unwrap_or("lenso.app-composition.v1")
.to_owned(),
requested_addons: json_string_list(value, "requestedAddons"),
requested_packs: json_string_list(value, "requestedPacks"),
service_actions: value
.get("serviceActions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(launchpad_composition_action_from_value)
.collect(),
})
}
fn launchpad_pack_fit_from_value(value: &Value) -> Option<AdminLaunchpadPackFitDto> {
Some(AdminLaunchpadPackFitDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
issues: json_string_list(value, "issues"),
name: value.get("name")?.as_str()?.to_owned(),
path: value.get("path")?.as_str()?.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("ready")
.to_owned(),
})
}
fn launchpad_capability_pack_from_value(value: &Value) -> Option<AdminLaunchpadCapabilityPackDto> {
Some(AdminLaunchpadCapabilityPackDto {
modules: json_string_list(value, "modules"),
name: value.get("name")?.as_str()?.to_owned(),
next_command: value
.get("nextCommand")
.and_then(Value::as_str)
.map(str::to_owned),
path: value.get("path")?.as_str()?.to_owned(),
services: json_string_list(value, "services"),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending")
.to_owned(),
})
}
fn launchpad_composition_action_from_value(
value: &Value,
) -> Option<AdminLaunchpadCompositionActionDto> {
Some(AdminLaunchpadCompositionActionDto {
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
id: value.get("id")?.as_str()?.to_owned(),
kind: value.get("kind")?.as_str()?.to_owned(),
label: value.get("label")?.as_str()?.to_owned(),
status: value
.get("status")
.and_then(Value::as_str)
.unwrap_or("recommended")
.to_owned(),
})
}
fn launchpad_change_plan_item_from_value(value: &Value) -> Option<AdminLaunchpadChangePlanItemDto> {
Some(AdminLaunchpadChangePlanItemDto {
action: value.get("action")?.as_str()?.to_owned(),
command: value
.get("command")
.and_then(Value::as_str)
.map(str::to_owned),
id: value.get("id")?.as_str()?.to_owned(),
kind: value.get("kind")?.as_str()?.to_owned(),
message: value
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
name: value.get("name")?.as_str()?.to_owned(),
safe: value.get("safe").and_then(Value::as_bool).unwrap_or(false),
})
}
fn launchpad_change_plan_status_from_value(
status: Option<&str>,
changes: &[AdminLaunchpadChangePlanItemDto],
blocked: &[AdminLaunchpadChangePlanItemDto],
) -> AdminLaunchpadChangePlanStatus {
match status {
Some("ready") => AdminLaunchpadChangePlanStatus::Ready,
Some("changes") => AdminLaunchpadChangePlanStatus::Changes,
Some("blocked") => AdminLaunchpadChangePlanStatus::Blocked,
Some("needs_setup") => AdminLaunchpadChangePlanStatus::NeedsSetup,
Some("failed") => AdminLaunchpadChangePlanStatus::Failed,
Some("empty") => AdminLaunchpadChangePlanStatus::Empty,
_ if !blocked.is_empty() => AdminLaunchpadChangePlanStatus::Blocked,
_ if !changes.is_empty() => AdminLaunchpadChangePlanStatus::Changes,
_ => AdminLaunchpadChangePlanStatus::Ready,
}
}
async fn service_module_lifecycle_response(
metadata: Vec<AdminModuleMetadata>,
install_state: AvailableModuleInstallStateContext,
) -> AdminServiceModuleLifecycleResponse {
let installed_modules = local_installed_remote_modules(MODULE_INSTALL_LEDGER_PATH);
let services_by_module = local_module_services(MODULE_SERVICES_PATH);
let release_history_by_service = local_service_release_history(SERVICE_RELEASE_LEDGER_PATH);
let environments_by_service = local_service_environments(SERVICE_ENVIRONMENTS_PATH);
let deployment_state = local_service_deployments(SERVICE_DEPLOYMENTS_PATH);
let metadata_by_name = metadata
.iter()
.filter(|module| matches!(module.source, ModuleSource::Remote))
.map(|module| (module.module_name.clone(), module))
.collect::<HashMap<_, _>>();
let provider_names_by_module = installed_modules
.iter()
.filter_map(|(module_name, receipt)| {
receipt
.service
.as_ref()
.and_then(|service| service.name.as_deref())
.filter(|provider_name| !provider_name.trim().is_empty())
.map(|provider_name| (module_name.clone(), provider_name.trim().to_owned()))
})
.collect::<HashMap<_, _>>();
let represented_provider_names = provider_names_by_module
.values()
.cloned()
.collect::<HashSet<_>>();
let mut module_names = HashSet::new();
module_names.extend(installed_modules.keys().cloned());
module_names.extend(
install_state
.remote_sources
.modules
.keys()
.filter(|module_name| !represented_provider_names.contains(*module_name))
.cloned(),
);
module_names.extend(
install_state
.running_base_urls
.keys()
.filter(|module_name| !represented_provider_names.contains(*module_name))
.cloned(),
);
module_names.extend(
services_by_module
.keys()
.filter(|module_name| !represented_provider_names.contains(*module_name))
.cloned(),
);
module_names.extend(metadata_by_name.keys().cloned());
let mut module_names = module_names.into_iter().collect::<Vec<_>>();
module_names.sort();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(800))
.build()
.ok();
let mut modules = Vec::new();
for module_name in module_names {
let provider_name = provider_names_by_module.get(&module_name);
modules.push(
service_module_lifecycle_module(
&module_name,
provider_name.map(String::as_str),
&install_state,
installed_modules.get(&module_name),
services_by_module
.get(&module_name)
.or_else(|| provider_name.and_then(|name| services_by_module.get(name)))
.cloned()
.unwrap_or_default(),
release_history_by_service
.get(provider_name.map(String::as_str).unwrap_or(&module_name))
.or_else(|| release_history_by_service.get(&module_name))
.cloned()
.unwrap_or_default(),
environments_by_service
.get(provider_name.map(String::as_str).unwrap_or(&module_name))
.or_else(|| environments_by_service.get(&module_name))
.cloned()
.unwrap_or_default(),
deployment_state
.current_by_service
.get(provider_name.map(String::as_str).unwrap_or(&module_name))
.or_else(|| deployment_state.current_by_service.get(&module_name))
.cloned()
.unwrap_or_default(),
deployment_state
.history_by_service
.get(provider_name.map(String::as_str).unwrap_or(&module_name))
.or_else(|| deployment_state.history_by_service.get(&module_name))
.cloned()
.unwrap_or_default(),
metadata_by_name.get(&module_name).copied(),
client.as_ref(),
)
.await,
);
}
let status = if modules.is_empty() {
AdminServiceModuleLifecycleStatus::Empty
} else if modules
.iter()
.all(|module| module.status == AdminServiceModuleLifecycleModuleStatus::Ready)
{
AdminServiceModuleLifecycleStatus::Ready
} else {
AdminServiceModuleLifecycleStatus::NeedsAttention
};
AdminServiceModuleLifecycleResponse {
version: 1,
status,
modules,
}
}
async fn service_module_lifecycle_module(
module_name: &str,
provider_name: Option<&str>,
install_state: &AvailableModuleInstallStateContext,
install_receipt: Option<&LocalModuleInstallLedgerModule>,
service_specs: Vec<LocalModuleServiceSpec>,
release_history: Vec<AdminServiceReleaseRecordDto>,
environments: Vec<AdminServiceEnvironmentDto>,
deployments: Vec<AdminServiceDeploymentObservationDto>,
deployment_history: Vec<AdminServiceDeploymentObservationDto>,
metadata: Option<&AdminModuleMetadata>,
client: Option<&reqwest::Client>,
) -> AdminServiceModuleLifecycleModuleDto {
let mut remote_source = install_state.remote_source_state(provider_name.unwrap_or(module_name));
if provider_name.is_some() {
reconcile_service_provider_remote_source(module_name, metadata, &mut remote_source);
}
let loaded =
metadata.is_some_and(|module| matches!(module.load_status, ModuleLoadStatus::Loaded));
let installed = install_receipt.is_some();
let configured = remote_source.configured;
let base_url = remote_source
.desired_base_url
.clone()
.or_else(|| remote_source.running_base_url.clone())
.or_else(|| {
metadata.and_then(|module| match module.source_diagnostics.as_ref()? {
AdminModuleSourceDiagnostics::Remote(remote) => {
Some(normalize_remote_base_url(&remote.base_url))
}
})
});
let manifest_url = base_url.as_deref().and_then(remote_module_manifest_url);
let status_url = service_module_status_url(
base_url.as_deref(),
install_receipt.and_then(|receipt| receipt.service.as_ref()),
);
let manifest_status = if base_url.is_some() && manifest_url.is_none() {
AdminServiceModuleManifestStatus::Skipped
} else {
service_module_manifest_status(metadata, manifest_url.as_deref(), client).await
};
let services = service_module_lifecycle_services(
provider_name.unwrap_or(module_name),
&service_specs,
client,
)
.await;
let service_status = service_module_status_summary(status_url.as_deref(), client).await;
let health_history =
record_service_module_health(module_name, status_url.as_deref(), &service_status);
let compatibility = service_module_compatibility(module_name, install_receipt);
let config = service_module_config(install_receipt, install_state);
let deployment = service_module_deployment(install_receipt);
let deployment_drift = deployments
.first()
.map(|deployment| deployment.drift.clone());
let deployment_next_action = deployments
.first()
.and_then(|deployment| deployment.next_action.clone());
let operations = service_module_operations(provider_name, module_name, metadata);
let module_release = install_receipt.and_then(module_release_from_receipt);
let latest_release = release_history.first().cloned();
let has_stale_state = services.iter().any(|service| {
!service.ready && (service.lock_file.is_some() || service.pid_file.is_some())
});
let has_service_not_ready = services.iter().any(|service| !service.ready);
let mut fixes = Vec::new();
let host_started = service_specs.iter().any(|service| service.auto_start);
let has_missing_config = host_started && !config.missing_env.is_empty();
let status = if has_stale_state {
fixes
.push("restart API/worker; remove stale lock/pid files if it remains stuck".to_owned());
AdminServiceModuleLifecycleModuleStatus::StaleState
} else if has_missing_config {
fixes.push(format!(
"set missing service env in {}: {}",
config.env_file,
config.missing_env.join(", ")
));
AdminServiceModuleLifecycleModuleStatus::MissingConfig
} else if remote_source.restart_pending {
fixes.push(
remote_source
.restart_reason
.clone()
.unwrap_or_else(|| "restart API and worker".to_owned()),
);
AdminServiceModuleLifecycleModuleStatus::RestartPending
} else if configured && !loaded {
fixes.push("restart API and worker, then inspect manifest errors".to_owned());
AdminServiceModuleLifecycleModuleStatus::ConfiguredNotLoaded
} else if manifest_status == AdminServiceModuleManifestStatus::Unreachable {
fixes.push("start the service or fix REMOTE_MODULES".to_owned());
AdminServiceModuleLifecycleModuleStatus::ManifestUnreachable
} else if has_service_not_ready {
fixes.push("start the declared service or inspect process logs".to_owned());
AdminServiceModuleLifecycleModuleStatus::ServiceNotReady
} else if loaded && (configured || installed || remote_source.running_base_url.is_some()) {
AdminServiceModuleLifecycleModuleStatus::Ready
} else {
fixes.push("install the service or add its provider to REMOTE_MODULES".to_owned());
AdminServiceModuleLifecycleModuleStatus::NotConfigured
};
AdminServiceModuleLifecycleModuleDto {
module_name: module_name.to_owned(),
provider_name: provider_name.map(str::to_owned),
status,
installed,
configured,
loaded,
restart_pending: remote_source.restart_pending,
base_url,
manifest_url,
manifest_status,
status_url,
service_status,
health_history,
compatibility,
config,
deployment,
environments,
deployments,
deployment_history,
deployment_drift,
deployment_next_action,
services,
operations,
module_release,
latest_release,
release_history,
fixes,
}
}
fn service_module_operations(
provider_name: Option<&str>,
module_name: &str,
metadata: Option<&AdminModuleMetadata>,
) -> Vec<AdminServiceOperationDto> {
let Some(metadata) = metadata else {
return Vec::new();
};
let mut operations = Vec::new();
for route in &metadata.http_routes {
let method = module_http_method(route.method);
let safe_probe = route
.operation
.as_ref()
.is_some_and(|operation| operation.safe_probe.is_some());
operations.push(AdminServiceOperationDto {
operation_id: route
.operation
.as_ref()
.and_then(|operation| operation.operation_id.clone())
.unwrap_or_else(|| format!("{module_name}/http/{method}:{}", route.path)),
provider_name: provider_name.map(str::to_owned),
module_name: module_name.to_owned(),
kind: AdminServiceOperationKindDto::HttpRoute,
name: route
.display_name
.clone()
.unwrap_or_else(|| format!("{method} {}", route.path)),
method: Some(method),
path: Some(route.path.clone()),
capability: route.capability.clone(),
summary: route
.operation
.as_ref()
.and_then(|operation| operation.summary.clone())
.or_else(|| route.display_name.clone())
.or_else(|| route.story_title.clone()),
safe_probe,
links: service_module_operation_links(provider_name, module_name),
next_action: service_module_operation_next_action(safe_probe),
});
}
if let Some(runtime) = metadata.runtime.as_ref() {
for function in &runtime.functions {
let safe_probe = function
.operation
.as_ref()
.is_some_and(|operation| operation.safe_probe.is_some());
operations.push(AdminServiceOperationDto {
operation_id: function
.operation
.as_ref()
.and_then(|operation| operation.operation_id.clone())
.unwrap_or_else(|| format!("{module_name}/runtime/{}", function.name)),
provider_name: provider_name.map(str::to_owned),
module_name: module_name.to_owned(),
kind: AdminServiceOperationKindDto::RuntimeFunction,
name: function.name.clone(),
method: None,
path: None,
capability: None,
summary: function
.operation
.as_ref()
.and_then(|operation| operation.summary.clone()),
safe_probe,
links: service_module_operation_links(provider_name, module_name),
next_action: service_module_operation_next_action(safe_probe),
});
}
}
if let Some(events) = metadata.events.as_ref() {
for handler in &events.handlers {
let safe_probe = handler
.operation
.as_ref()
.is_some_and(|operation| operation.safe_probe.is_some());
operations.push(AdminServiceOperationDto {
operation_id: handler
.operation
.as_ref()
.and_then(|operation| operation.operation_id.clone())
.unwrap_or_else(|| format!("{module_name}/event/{}", handler.name)),
provider_name: provider_name.map(str::to_owned),
module_name: module_name.to_owned(),
kind: AdminServiceOperationKindDto::EventHandler,
name: handler.name.clone(),
method: None,
path: None,
capability: None,
summary: handler
.operation
.as_ref()
.and_then(|operation| operation.summary.clone())
.or_else(|| Some(handler.event_name.clone())),
safe_probe,
links: service_module_operation_links(provider_name, module_name),
next_action: service_module_operation_next_action(safe_probe),
});
}
}
if let Some(AdminSurface::DeclarativeCustom(admin)) = metadata.admin.as_ref() {
for action in &admin.actions {
let safe_probe = action
.operation
.as_ref()
.is_some_and(|operation| operation.safe_probe.is_some());
operations.push(AdminServiceOperationDto {
operation_id: action
.operation
.as_ref()
.and_then(|operation| operation.operation_id.clone())
.unwrap_or_else(|| format!("{module_name}/action/{}", action.name)),
provider_name: provider_name.map(str::to_owned),
module_name: module_name.to_owned(),
kind: AdminServiceOperationKindDto::AdminAction,
name: action.name.clone(),
method: None,
path: None,
capability: Some(action.capability.clone()),
summary: action
.operation
.as_ref()
.and_then(|operation| operation.summary.clone())
.or_else(|| Some(action.label.clone())),
safe_probe,
links: service_module_operation_links(provider_name, module_name),
next_action: service_module_operation_next_action(safe_probe),
});
}
}
operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id));
operations
}
fn module_http_method(method: ModuleHttpMethod) -> String {
match method {
ModuleHttpMethod::Get => "GET".to_owned(),
ModuleHttpMethod::Post => "POST".to_owned(),
ModuleHttpMethod::Put => "PUT".to_owned(),
ModuleHttpMethod::Patch => "PATCH".to_owned(),
ModuleHttpMethod::Delete => "DELETE".to_owned(),
_ => format!("{method:?}").to_uppercase(),
}
}
fn service_module_operation_links(
provider_name: Option<&str>,
module_name: &str,
) -> AdminServiceOperationLinksDto {
let query = provider_name.unwrap_or(module_name);
AdminServiceOperationLinksDto {
remote_calls: Some(format!("/operations/remote-calls?module={module_name}")),
runtime: Some(format!("/operations/functions?module={module_name}")),
story: format!("/?q={query}"),
technical_operations: format!("/operations?q={query}"),
}
}
fn service_module_operation_next_action(safe_probe: bool) -> String {
if safe_probe {
"run lenso service verify for this operation"
} else {
"add safeProbe metadata before active checks"
}
.to_owned()
}
fn reconcile_service_provider_remote_source(
module_name: &str,
metadata: Option<&AdminModuleMetadata>,
remote_source: &mut AdminModuleRemoteSourceInstallStateDto,
) {
reconcile_service_provider_remote_source_base_url(
module_name,
metadata_remote_base_url(metadata).as_deref(),
remote_source,
);
}
fn reconcile_service_provider_remote_source_base_url(
module_name: &str,
running_module_base_url: Option<&str>,
remote_source: &mut AdminModuleRemoteSourceInstallStateDto,
) {
let Some(desired_base_url) = remote_source.desired_base_url.as_deref() else {
return;
};
let Some(running_module_base_url) = running_module_base_url else {
return;
};
if running_module_base_url == service_provider_module_base_url(desired_base_url, module_name) {
remote_source.running_base_url = Some(normalize_remote_base_url(desired_base_url));
remote_source.restart_pending = false;
remote_source.restart_reason = None;
}
}
fn metadata_remote_base_url(metadata: Option<&AdminModuleMetadata>) -> Option<String> {
metadata.and_then(|module| match module.source_diagnostics.as_ref()? {
AdminModuleSourceDiagnostics::Remote(remote) => {
Some(normalize_remote_base_url(&remote.base_url))
}
})
}
fn service_provider_module_base_url(base_url: &str, module_name: &str) -> String {
format!(
"{}/modules/{module_name}",
normalize_remote_base_url(base_url)
)
}
fn service_module_status_url(
base_url: Option<&str>,
service: Option<&LocalServiceModuleMetadata>,
) -> Option<String> {
let service = service?;
if let Some(status_url) = service
.status_url
.as_deref()
.filter(|url| !url.trim().is_empty())
{
return Some(status_url.trim().to_owned());
}
let base_url = base_url?;
let base_url = base_url.trim().trim_end_matches('/');
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return None;
}
let path = service
.status_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.unwrap_or("status");
if path.starts_with("http://") || path.starts_with("https://") {
return Some(path.to_owned());
}
let base = reqwest::Url::parse(&format!("{base_url}/")).ok()?;
base.join(path).ok().map(|url| url.to_string())
}
async fn service_module_status_summary(
status_url: Option<&str>,
client: Option<&reqwest::Client>,
) -> AdminServiceModuleServiceStatusDto {
let Some(status_url) = status_url else {
return service_module_status_unknown(false, None);
};
let Some(client) = client else {
return service_module_status_unknown(false, None);
};
let response = match client.get(status_url).send().await {
Ok(response) => response,
Err(error) => {
return AdminServiceModuleServiceStatusDto {
checked: true,
state: AdminServiceModuleServiceStatusState::Unreachable,
error: Some(error.to_string()),
checks: Vec::new(),
};
}
};
if !response.status().is_success() {
return AdminServiceModuleServiceStatusDto {
checked: true,
state: AdminServiceModuleServiceStatusState::Unreachable,
error: Some(format!(
"status endpoint returned HTTP {}",
response.status()
)),
checks: Vec::new(),
};
}
let body = match response.json::<Value>().await {
Ok(body) => body,
Err(error) => {
return AdminServiceModuleServiceStatusDto {
checked: true,
state: AdminServiceModuleServiceStatusState::Unreachable,
error: Some(format!("status endpoint returned invalid JSON: {error}")),
checks: Vec::new(),
};
}
};
AdminServiceModuleServiceStatusDto {
checked: true,
state: service_module_status_state(body.get("state").and_then(Value::as_str)),
error: None,
checks: service_module_status_checks(&body),
}
}
fn service_module_status_unknown(
checked: bool,
error: Option<String>,
) -> AdminServiceModuleServiceStatusDto {
AdminServiceModuleServiceStatusDto {
checked,
state: AdminServiceModuleServiceStatusState::Unknown,
error,
checks: Vec::new(),
}
}
fn service_module_status_state(value: Option<&str>) -> AdminServiceModuleServiceStatusState {
match value.unwrap_or("ready") {
"ready" => AdminServiceModuleServiceStatusState::Ready,
"degraded" => AdminServiceModuleServiceStatusState::Degraded,
"starting" => AdminServiceModuleServiceStatusState::Starting,
"unreachable" => AdminServiceModuleServiceStatusState::Unreachable,
_ => AdminServiceModuleServiceStatusState::Unknown,
}
}
fn service_module_status_checks(body: &Value) -> Vec<AdminServiceModuleServiceStatusCheckDto> {
body.get("checks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|check| {
Some(AdminServiceModuleServiceStatusCheckDto {
name: check.get("name")?.as_str()?.to_owned(),
status: check.get("status")?.as_str()?.to_owned(),
detail: check
.get("detail")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
})
.collect()
}
fn service_module_compatibility(
module_name: &str,
install_receipt: Option<&LocalModuleInstallLedgerModule>,
) -> AdminServiceModuleCompatibilityDto {
let declared = install_receipt.and_then(|receipt| receipt.compatibility.clone());
let issue = module_compatibility_issue(module_name, declared.as_ref());
let override_allowed = issue.is_some();
let issue_message = issue.as_ref().map(|issue| issue.message.clone());
let issue_fix = issue.map(|issue| issue.fix);
AdminServiceModuleCompatibilityDto {
state: if override_allowed {
AdminServiceModuleCompatibilityState::Blocked
} else if declared.is_some() {
AdminServiceModuleCompatibilityState::Compatible
} else {
AdminServiceModuleCompatibilityState::Unknown
},
declared,
host: host_module_compatibility(),
issue: issue_message,
fix: issue_fix,
override_allowed,
}
}
fn service_module_config(
install_receipt: Option<&LocalModuleInstallLedgerModule>,
install_state: &AvailableModuleInstallStateContext,
) -> AdminServiceModuleConfigDto {
let required_env = install_receipt
.and_then(|receipt| receipt.service.as_ref())
.map(|service| service.required_env.clone())
.unwrap_or_default();
let configured_env = required_env
.iter()
.filter(|key| install_state.remote_sources.keys.contains(*key))
.cloned()
.collect::<Vec<_>>();
let missing_env = required_env
.iter()
.filter(|key| !install_state.remote_sources.keys.contains(*key))
.cloned()
.collect::<Vec<_>>();
AdminServiceModuleConfigDto {
env_file: install_state.remote_sources.env_file.clone(),
required_env,
configured_env,
missing_env,
}
}
fn service_module_deployment(
install_receipt: Option<&LocalModuleInstallLedgerModule>,
) -> Option<AdminServiceModuleDeploymentDto> {
install_receipt.and_then(|receipt| {
receipt.deployment.clone().or_else(|| {
receipt
.service
.as_ref()
.and_then(|service| service.deployment.clone())
})
})
}
fn record_service_module_health(
module_name: &str,
status_url: Option<&str>,
status: &AdminServiceModuleServiceStatusDto,
) -> Vec<AdminServiceModuleHealthCheckDto> {
let path = FsPath::new(SERVICE_MODULE_HEALTH_PATH);
let mut file = read_service_module_health_file(path);
if status.checked
&& let Some(status_url) = status_url
{
file.records.push(AdminServiceModuleHealthCheckDto {
module_name: module_name.to_owned(),
checked_at_unix_ms: current_unix_ms(),
status_url: status_url.to_owned(),
state: service_module_status_state_label(&status.state).to_owned(),
error: status.error.clone(),
});
if file.records.len() > 200 {
let overflow = file.records.len() - 200;
file.records.drain(0..overflow);
}
let _ = write_service_module_health_file(path, &file);
}
file.records
.into_iter()
.rev()
.filter(|record| record.module_name == module_name)
.take(20)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn read_service_module_health_file(path: &FsPath) -> LocalServiceModuleHealthFile {
fs::read_to_string(path)
.ok()
.and_then(|source| serde_json::from_str::<LocalServiceModuleHealthFile>(&source).ok())
.unwrap_or(LocalServiceModuleHealthFile {
records: Vec::new(),
version: 1,
})
}
fn write_service_module_health_file(
path: &FsPath,
file: &LocalServiceModuleHealthFile,
) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
fs::write(path, bytes)
}
fn current_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or_default()
}
fn service_module_status_state_label(state: &AdminServiceModuleServiceStatusState) -> &'static str {
match state {
AdminServiceModuleServiceStatusState::Ready => "ready",
AdminServiceModuleServiceStatusState::Degraded => "degraded",
AdminServiceModuleServiceStatusState::Starting => "starting",
AdminServiceModuleServiceStatusState::Unreachable => "unreachable",
AdminServiceModuleServiceStatusState::Unknown => "unknown",
}
}
async fn service_module_manifest_status(
metadata: Option<&AdminModuleMetadata>,
manifest_url: Option<&str>,
client: Option<&reqwest::Client>,
) -> AdminServiceModuleManifestStatus {
if let Some(module) = metadata
&& let Some(AdminModuleSourceDiagnostics::Remote(remote)) =
module.source_diagnostics.as_ref()
&& matches!(module.load_status, ModuleLoadStatus::Loaded)
{
return if remote.last_load_error.is_none() {
AdminServiceModuleManifestStatus::Reachable
} else {
AdminServiceModuleManifestStatus::Unreachable
};
}
let Some(manifest_url) = manifest_url else {
return AdminServiceModuleManifestStatus::NotConfigured;
};
let Some(client) = client else {
return AdminServiceModuleManifestStatus::Skipped;
};
if remote_service_ready(client, manifest_url).await {
AdminServiceModuleManifestStatus::Reachable
} else {
AdminServiceModuleManifestStatus::Unreachable
}
}
async fn service_module_lifecycle_services(
module_name: &str,
services: &[LocalModuleServiceSpec],
client: Option<&reqwest::Client>,
) -> Vec<AdminServiceModuleLifecycleServiceDto> {
let state_dir = FsPath::new(MODULE_SERVICES_PATH)
.parent()
.unwrap_or_else(|| FsPath::new("."));
let mut items = Vec::new();
for service in services {
let ready = match client {
Some(client) => remote_service_ready(client, &service.ready_url).await,
None => false,
};
let lock_file_path =
remote_module_service_state_path(state_dir, module_name, service, "lock");
let pid_file_path =
remote_module_service_state_path(state_dir, module_name, service, "pid");
items.push(AdminServiceModuleLifecycleServiceDto {
name: service.name.clone(),
ready_url: service.ready_url.clone(),
ready,
auto_start: service.auto_start,
lock_file: lock_file_path
.exists()
.then(|| lock_file_path.display().to_string()),
pid_file: pid_file_path
.exists()
.then(|| pid_file_path.display().to_string()),
});
}
items
}
fn local_installed_remote_modules(
path: impl AsRef<FsPath>,
) -> HashMap<String, LocalModuleInstallLedgerModule> {
let Ok(source) = fs::read_to_string(path) else {
return HashMap::new();
};
let Ok(ledger) = serde_json::from_str::<LocalModuleInstallLedger>(&source) else {
return HashMap::new();
};
ledger
.modules
.into_iter()
.filter(|module| module.source.as_deref().unwrap_or("remote") == "remote")
.map(|module| (module.module_name.clone(), module))
.collect()
}
fn local_service_release_history(
path: impl AsRef<FsPath>,
) -> HashMap<String, Vec<AdminServiceReleaseRecordDto>> {
let Ok(source) = fs::read_to_string(path) else {
return HashMap::new();
};
let Ok(ledger) = serde_json::from_str::<Value>(&source) else {
return HashMap::new();
};
let mut releases_by_service: HashMap<String, Vec<AdminServiceReleaseRecordDto>> =
HashMap::new();
for release in ledger
.get("releases")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(record) = service_release_record_from_value(release) else {
continue;
};
releases_by_service
.entry(record.service_name.clone())
.or_default()
.push(record);
}
for releases in releases_by_service.values_mut() {
releases.sort_by(|left, right| right.applied_at_unix_ms.cmp(&left.applied_at_unix_ms));
releases.truncate(5);
}
releases_by_service
}
fn local_service_environments(
path: impl AsRef<FsPath>,
) -> HashMap<String, Vec<AdminServiceEnvironmentDto>> {
let Ok(source) = fs::read_to_string(path) else {
return HashMap::new();
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return HashMap::new();
};
let mut environments_by_service: HashMap<String, Vec<AdminServiceEnvironmentDto>> =
HashMap::new();
for environment in file
.get("environments")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(environment) = service_environment_from_value(environment) else {
continue;
};
environments_by_service
.entry(environment.service_name.clone())
.or_default()
.push(environment);
}
for environments in environments_by_service.values_mut() {
environments.sort_by(|left, right| left.name.cmp(&right.name));
}
environments_by_service
}
fn local_service_deployments(path: impl AsRef<FsPath>) -> LocalServiceDeploymentState {
let Ok(source) = fs::read_to_string(path) else {
return LocalServiceDeploymentState::default();
};
let Ok(file) = serde_json::from_str::<Value>(&source) else {
return LocalServiceDeploymentState::default();
};
LocalServiceDeploymentState {
current_by_service: service_deployments_by_service(&file, "observations"),
history_by_service: service_deployments_by_service(&file, "history"),
}
}
fn service_deployments_by_service(
file: &Value,
key: &str,
) -> HashMap<String, Vec<AdminServiceDeploymentObservationDto>> {
let mut deployments_by_service = HashMap::new();
for observation in file
.get(key)
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(observation) = service_deployment_from_value(observation) {
deployments_by_service
.entry(observation.service_name.clone())
.or_insert_with(Vec::new)
.push(observation);
}
}
for deployments in deployments_by_service.values_mut() {
deployments.sort_by(|left, right| right.observed_at_unix_ms.cmp(&left.observed_at_unix_ms));
}
deployments_by_service
}
fn service_environment_from_value(value: &Value) -> Option<AdminServiceEnvironmentDto> {
Some(AdminServiceEnvironmentDto {
name: json_string(value, "name")?,
service_name: json_string(value, "serviceName")?,
target: json_string(value, "target").unwrap_or_else(|| "kubernetes".to_owned()),
namespace: json_string(value, "namespace"),
kube_context: json_string(value, "kubeContext"),
image: json_string(value, "image"),
public_base_url: json_string(value, "publicBaseUrl"),
manifest_reference: json_string(value, "manifestReference"),
release_track: json_string(value, "releaseTrack"),
})
}
fn service_deployment_from_value(value: &Value) -> Option<AdminServiceDeploymentObservationDto> {
Some(AdminServiceDeploymentObservationDto {
service_name: json_string(value, "serviceName")?,
environment: json_string(value, "environment")?,
target: json_string(value, "target").unwrap_or_else(|| "kubernetes".to_owned()),
observed_at_unix_ms: value.get("observedAtUnixMs").and_then(Value::as_u64),
state: json_string(value, "state").unwrap_or_else(|| "unknown".to_owned()),
drift: json_string(value, "drift").unwrap_or_else(|| "unknown".to_owned()),
operator: value.get("operator").map(operator_deployment_from_value),
cluster: value.get("cluster").map(kubernetes_deployment_from_value),
host: value.get("host").map(service_deployment_host_from_value),
checks: value
.get("checks")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(service_deployment_check_from_value)
.collect(),
next_action: json_string(value, "nextAction"),
})
}
fn operator_deployment_from_value(value: &Value) -> AdminServiceDeploymentOperatorObservationDto {
AdminServiceDeploymentOperatorObservationDto {
resource: json_string(value, "resource"),
namespace: json_string(value, "namespace"),
observed_generation: value.get("observedGeneration").and_then(Value::as_u64),
conditions: value
.get("conditions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(operator_condition_from_value)
.collect(),
}
}
fn operator_condition_from_value(value: &Value) -> AdminServiceDeploymentOperatorConditionDto {
AdminServiceDeploymentOperatorConditionDto {
type_: json_string(value, "type"),
status: json_string(value, "status"),
reason: json_string(value, "reason"),
message: json_string(value, "message"),
last_transition_time: json_string(value, "lastTransitionTime"),
}
}
fn kubernetes_deployment_from_value(value: &Value) -> AdminKubernetesDeploymentObservationDto {
AdminKubernetesDeploymentObservationDto {
namespace: json_string(value, "namespace"),
deployment: json_string(value, "deployment"),
ready_replicas: json_u32(value, "readyReplicas"),
desired_replicas: json_u32(value, "desiredReplicas"),
available_replicas: json_u32(value, "availableReplicas"),
image: json_string(value, "image"),
release_id: json_string(value, "releaseId"),
manifest_reference: json_string(value, "manifestReference"),
service_endpoint: json_string(value, "serviceEndpoint"),
ingress_host: json_string(value, "ingressHost"),
}
}
fn service_deployment_host_from_value(value: &Value) -> AdminServiceDeploymentHostObservationDto {
AdminServiceDeploymentHostObservationDto {
release_id: json_string(value, "releaseId"),
candidate_version: json_string(value, "candidateVersion"),
}
}
fn service_deployment_check_from_value(value: &Value) -> Option<AdminServiceDeploymentCheckDto> {
Some(AdminServiceDeploymentCheckDto {
name: json_string(value, "name")?,
status: json_string(value, "status")?,
detail: json_string(value, "detail"),
})
}
fn json_string(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn json_string_list(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
}
fn json_u32(value: &Value, key: &str) -> Option<u32> {
value
.get(key)
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
}
fn service_release_record_from_value(value: &Value) -> Option<AdminServiceReleaseRecordDto> {
let service_name = value.get("serviceName").and_then(Value::as_str)?.to_owned();
Some(AdminServiceReleaseRecordDto {
id: value.get("id").and_then(Value::as_str).map(str::to_owned),
service_name,
environment: value
.pointer("/environment/name")
.and_then(Value::as_str)
.map(str::to_owned),
target: value
.pointer("/environment/target")
.and_then(Value::as_str)
.map(str::to_owned),
applied_at_unix_ms: value.get("appliedAtUnixMs").and_then(Value::as_u64),
risk: value
.get("risk")
.and_then(Value::as_str)
.unwrap_or("safe")
.to_owned(),
current_version: value
.pointer("/current/version")
.and_then(Value::as_str)
.filter(|version| !version.trim().is_empty())
.map(str::to_owned),
candidate_version: value
.pointer("/candidate/version")
.and_then(Value::as_str)
.filter(|version| !version.trim().is_empty())
.map(str::to_owned),
current_manifest_reference: value
.pointer("/current/manifestReference")
.and_then(Value::as_str)
.filter(|reference| !reference.trim().is_empty())
.map(str::to_owned),
candidate_manifest_reference: value
.pointer("/candidate/manifestReference")
.and_then(Value::as_str)
.filter(|reference| !reference.trim().is_empty())
.map(str::to_owned),
candidate_package_reference: value
.pointer("/candidate/packageReference")
.and_then(Value::as_str)
.filter(|reference| !reference.trim().is_empty())
.map(str::to_owned),
rollback_target: value
.get("rollbackTarget")
.and_then(Value::as_str)
.filter(|reference| !reference.trim().is_empty())
.map(str::to_owned),
})
}
fn local_module_services(path: impl AsRef<FsPath>) -> HashMap<String, Vec<LocalModuleServiceSpec>> {
let Ok(source) = fs::read_to_string(path) else {
return HashMap::new();
};
let Ok(file) = serde_json::from_str::<LocalModuleServicesFile>(&source) else {
return HashMap::new();
};
file.modules
.into_iter()
.map(|module| (module.module_name, module.services))
.collect()
}
fn remote_module_manifest_url(base_url: &str) -> Option<String> {
let base_url = base_url.trim().trim_end_matches('/');
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return None;
}
Some(if base_url.ends_with("/manifest") {
base_url.to_owned()
} else {
format!("{base_url}/manifest")
})
}
async fn remote_service_ready(client: &reqwest::Client, url: &str) -> bool {
client
.get(url)
.send()
.await
.is_ok_and(|response| response.status().is_success())
}
fn remote_module_service_state_path(
services_state_dir: &FsPath,
module_name: &str,
service: &LocalModuleServiceSpec,
extension: &str,
) -> PathBuf {
services_state_dir.join(format!(
"remote-{}-{}.{}",
remote_module_service_state_segment(module_name),
remote_module_service_state_segment(&service.name),
extension
))
}
fn remote_module_service_state_segment(value: &str) -> String {
let mut segment = String::new();
let mut previous_dash = false;
for character in value.chars() {
if character.is_ascii_alphanumeric() {
segment.push(character.to_ascii_lowercase());
previous_dash = false;
} else if !segment.is_empty() && !previous_dash {
segment.push('-');
previous_dash = true;
}
}
while segment.ends_with('-') {
segment.pop();
}
if segment.is_empty() {
"service".to_owned()
} else {
segment
}
}
async fn install_available_module_response(
module_name: String,
ctx: &RequestContext,
) -> Result<AdminModuleInstallResponse, ApiErrorResponse> {
let catalog_entry =
find_installable_catalog_entry(&module_name).map_err(|error| install_error(error, ctx))?;
if catalog_entry.source == "linked" {
return install_linked_available_module_response(catalog_entry, ctx).await;
}
let base_url = install_base_url(&catalog_entry).map_err(|error| install_error(error, ctx))?;
validate_installable_catalog_entry(&catalog_entry, &base_url)
.map_err(|error| install_error(error, ctx))?;
let env_file_path = PathBuf::from(".env");
let console_registry_file_path = PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH);
write_runtime_console_extension_registry(
&console_registry_file_path,
&catalog_entry,
&base_url,
)
.await
.map_err(|error| install_error(error, ctx))?;
let remote_source_name = catalog_entry_remote_source_name(&catalog_entry).to_owned();
write_remote_modules_env(&env_file_path, &remote_source_name, &base_url)
.map_err(|error| install_error(error, ctx))?;
write_available_module_install_ledger(
PathBuf::from(MODULE_INSTALL_LEDGER_PATH),
&catalog_entry,
&remote_source_name,
&base_url,
)
.map_err(|error| install_error(error, ctx))?;
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
&env_file_path,
&console_registry_file_path,
);
let state = catalog_entry_install_state(&catalog_entry, &install_state);
Ok(AdminModuleInstallResponse {
module_release: module_release_from_catalog_entry(&catalog_entry),
module_name: catalog_entry.name,
manifest_reference: catalog_entry.manifest_reference,
linked_source: state.linked_source,
remote_source: state.remote_source,
console_plan: state.console_plan,
restart_required: true,
})
}
fn uninstall_available_module_response(
module_name: String,
ctx: &RequestContext,
) -> Result<AdminModuleInstallResponse, ApiErrorResponse> {
let catalog_entry =
find_installable_catalog_entry(&module_name).map_err(|error| install_error(error, ctx))?;
if catalog_entry.source == "linked" {
return uninstall_linked_available_module_response(catalog_entry, ctx);
}
let env_file_path = PathBuf::from(".env");
let console_registry_file_path = PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH);
let legacy_console_plan_file_path = PathBuf::from(".lenso/console-package-install-plan.json");
let remote_source_name = catalog_entry_remote_source_name(&catalog_entry).to_owned();
remove_remote_modules_env(&env_file_path, &remote_source_name)
.map_err(|error| install_error(error, ctx))?;
remove_runtime_console_extension_registry_module(
&console_registry_file_path,
&catalog_entry.name,
)
.map_err(|error| install_error(error, ctx))?;
remove_console_extension_module_dir(&catalog_entry.name)
.map_err(|error| install_error(error, ctx))?;
remove_console_package_install_plan_module(&legacy_console_plan_file_path, &catalog_entry.name)
.map_err(|error| install_error(error, ctx))?;
remove_module_install_ledger_module(
PathBuf::from(MODULE_INSTALL_LEDGER_PATH),
&catalog_entry.name,
)
.map_err(|error| install_error(error, ctx))?;
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
&env_file_path,
&console_registry_file_path,
);
let state = catalog_entry_install_state(&catalog_entry, &install_state);
Ok(AdminModuleInstallResponse {
module_release: module_release_from_catalog_entry(&catalog_entry),
module_name: catalog_entry.name,
manifest_reference: catalog_entry.manifest_reference,
linked_source: state.linked_source,
remote_source: state.remote_source,
console_plan: state.console_plan,
restart_required: true,
})
}
async fn install_linked_available_module_response(
catalog_entry: LocalModuleCatalogEntry,
ctx: &RequestContext,
) -> Result<AdminModuleInstallResponse, ApiErrorResponse> {
if catalog_entry.archived_at.is_some() {
return Err(install_error(
AppError::new(
ErrorCode::Validation,
format!("available module {} is archived", catalog_entry.name),
),
ctx,
));
}
write_linked_module_profile_env(PathBuf::from(".env"))
.map_err(|error| install_error(error, ctx))?;
write_linked_module_enabled_env(PathBuf::from(".env"), &catalog_entry.name, true)
.map_err(|error| install_error(error, ctx))?;
write_linked_runtime_console_extensions(
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
&catalog_entry,
)
.await
.map_err(|error| install_error(error, ctx))?;
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
PathBuf::from(".env"),
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
);
let state = install_state.install_state_for_source(&catalog_entry.name, ModuleSource::Linked);
Ok(AdminModuleInstallResponse {
module_release: None,
module_name: catalog_entry.name,
manifest_reference: catalog_entry.manifest_reference,
linked_source: state.linked_source,
remote_source: state.remote_source,
console_plan: state.console_plan,
restart_required: true,
})
}
fn uninstall_linked_available_module_response(
catalog_entry: LocalModuleCatalogEntry,
ctx: &RequestContext,
) -> Result<AdminModuleInstallResponse, ApiErrorResponse> {
write_linked_module_enabled_env(PathBuf::from(".env"), &catalog_entry.name, false)
.map_err(|error| install_error(error, ctx))?;
remove_runtime_console_extension_registry_module(
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
&catalog_entry.name,
)
.map_err(|error| install_error(error, ctx))?;
let metadata = admin_module_metadata_snapshot().modules;
let install_state = AvailableModuleInstallStateContext::from_paths(
&metadata,
PathBuf::from(".env"),
PathBuf::from(CONSOLE_EXTENSION_REGISTRY_PATH),
);
let state = install_state.install_state_for_source(&catalog_entry.name, ModuleSource::Linked);
Ok(AdminModuleInstallResponse {
module_release: None,
module_name: catalog_entry.name,
manifest_reference: catalog_entry.manifest_reference,
linked_source: state.linked_source,
remote_source: state.remote_source,
console_plan: state.console_plan,
restart_required: true,
})
}
fn install_error(error: AppError, ctx: &RequestContext) -> ApiErrorResponse {
ApiErrorResponse::with_context(error, ctx)
}
#[utoipa::path(
get,
path = "/admin/data/schema",
operation_id = "admin_data_list_schemas",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "All admin-capable modules' schemas", body = AdminSchemaListResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn list_schemas(
_admin: AdminActor,
HttpRequestContext(_request_ctx): HttpRequestContext,
) -> Result<Json<AdminSchemaListResponse>, ApiErrorResponse> {
Ok(Json(AdminSchemaListResponse {
modules: schema_response_modules(admin_modules()),
}))
}
#[utoipa::path(
post,
path = "/admin/data/schema/refresh",
operation_id = "admin_data_refresh_schemas",
tag = "admin-data",
params(("authorization" = String, Header, description = "Development service bearer token")),
responses(
(status = 200, description = "Refreshed admin-capable modules' schemas", body = AdminSchemaRefreshResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 502, description = "Admin module refresh is unavailable or failed", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn refresh_schemas(
_admin: AdminActor,
HttpRequestContext(request_ctx): HttpRequestContext,
) -> Result<Json<AdminSchemaRefreshResponse>, ApiErrorResponse> {
let refresher = admin_refresher().ok_or_else(|| {
ApiErrorResponse::with_context(
AppError::new(
ErrorCode::ExternalDependency,
"admin module refresh is unavailable",
)
.retryable(),
&request_ctx,
)
})?;
let modules = refresher
.refresh_admin_modules()
.await
.map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
install_admin_modules(modules.clone());
if let Some(metadata_refresher) = admin_metadata_refresher() {
let metadata = metadata_refresher
.refresh_admin_module_metadata()
.await
.map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
install_admin_module_metadata(metadata);
}
Ok(Json(AdminSchemaRefreshResponse {
modules: schema_response_modules(modules),
}))
}
fn metadata_response_modules(modules: Vec<AdminModuleMetadata>) -> Vec<AdminModuleMetadataDto> {
modules
.iter()
.map(|m| {
let manifest_lints = lint_module_manifest_parts(
m.source,
&m.module_name,
m.admin.as_ref(),
&m.http_routes,
m.runtime.as_ref(),
m.events.as_ref(),
m.lifecycle.as_ref(),
&m.console,
&m.console_slots,
&m.console_contributions,
&m.capabilities,
&m.dependencies,
);
AdminModuleMetadataDto {
module_name: m.module_name.clone(),
source: m.source,
status: admin_module_status(&m.load_status),
error: load_error_message(&m.load_status),
source_diagnostics: source_diagnostics_dto(m.source_diagnostics.clone()),
http_routes: m.http_routes.clone(),
runtime: m.runtime.clone(),
events: m.events.clone(),
lifecycle: m.lifecycle.clone(),
console: m.console.clone(),
console_slots: m.console_slots.clone(),
console_contributions: m.console_contributions.clone(),
governance: module_governance(m, &manifest_lints),
manifest_lints,
story_display: m
.story_display
.clone()
.into_iter()
.map(Into::into)
.collect(),
capabilities: m.capabilities.clone(),
dependencies: m.dependencies.clone(),
admin: m.admin.clone(),
}
})
.collect()
}
fn module_registry_snapshot_response(
modules: Vec<AdminModuleMetadata>,
install_state: &AvailableModuleInstallStateContext,
) -> AdminModuleRegistrySnapshotResponse {
let modules = modules
.into_iter()
.filter(|module| matches!(module.source, ModuleSource::Remote))
.map(|module| module_registry_snapshot_module(module, install_state))
.collect::<Vec<_>>();
let issue_count = modules
.iter()
.filter(|module| {
matches!(
module.status,
AdminModuleRegistrySnapshotModuleStatus::NeedsAttention
)
})
.count();
let issues = modules
.iter()
.filter(|module| {
matches!(
module.status,
AdminModuleRegistrySnapshotModuleStatus::NeedsAttention
)
})
.map(|module| AdminModuleRegistrySnapshotIssueDto {
group: "Manifest".to_owned(),
message: format!("{} service metadata needs attention", module.name),
fix: "refresh module metadata and verify service manifest configuration".to_owned(),
})
.collect::<Vec<_>>();
AdminModuleRegistrySnapshotResponse {
version: 1,
status: if issue_count == 0 {
AdminModuleRegistrySnapshotStatus::Passed
} else {
AdminModuleRegistrySnapshotStatus::Failed
},
catalog: AdminModuleRegistrySnapshotCatalogDto {
modules: modules.len(),
registry_file: "host-admin-module-metadata".to_owned(),
version: 1,
},
issues,
modules,
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleCatalog {
#[serde(default)]
modules: Vec<LocalModuleCatalogEntry>,
#[serde(default = "default_module_catalog_version")]
version: u8,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleCatalogEntry {
name: String,
version: String,
source: String,
manifest_reference: String,
#[serde(default)]
protocol: Option<String>,
#[serde(default)]
provider: Option<LocalModuleReleaseProvider>,
#[serde(default, rename = "providedBy")]
provided_by: Option<String>,
#[serde(default, rename = "serviceManifest")]
service_manifest: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
console_packages: Vec<LocalModuleCatalogConsolePackage>,
#[serde(default)]
compatibility: Option<AdminModuleCompatibilityDto>,
#[serde(default)]
archived_at: Option<String>,
#[serde(default)]
archive_reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleReleaseProvider {
#[serde(default)]
name: Option<String>,
#[serde(default, alias = "service_package")]
service_package: Option<String>,
#[serde(default, alias = "service_manifest")]
service_manifest: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleCatalogConsolePackage {
package_name: String,
export_name: String,
#[serde(default, alias = "bundle_url")]
bundle_url: Option<String>,
#[serde(default)]
entry: Option<String>,
#[serde(default, alias = "host_api")]
host_api: Option<String>,
#[serde(default)]
route: Option<String>,
#[serde(default)]
required_capabilities: Vec<String>,
#[serde(default)]
styles: Vec<String>,
#[serde(default)]
version: Option<String>,
}
#[derive(Debug)]
struct AvailableModuleInstallStateContext {
console_plan: LocalConsolePackageInstallPlanState,
linked_modules: LocalLinkedModulesEnvState,
registered_modules: HashSet<String>,
remote_sources: LocalRemoteModulesEnvState,
running_base_urls: HashMap<String, String>,
}
#[derive(Debug)]
struct LocalLinkedModulesEnvState {
env_file: String,
error: Option<String>,
enabled: HashMap<String, bool>,
}
#[derive(Debug)]
struct LocalRemoteModulesEnvState {
env_file: String,
error: Option<String>,
keys: HashSet<String>,
modules: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleInstallLedger {
#[serde(default)]
modules: Vec<LocalModuleInstallLedgerModule>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleInstallLedgerModule {
module_name: String,
#[serde(default)]
source: Option<String>,
#[serde(default)]
compatibility: Option<AdminModuleCompatibilityDto>,
#[serde(default)]
deployment: Option<AdminServiceModuleDeploymentDto>,
#[serde(default)]
service: Option<LocalServiceModuleMetadata>,
#[serde(default)]
module_release: Option<LocalModuleReleaseReceipt>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleReleaseReceipt {
#[serde(default)]
manifest_reference: Option<String>,
#[serde(default)]
manifest_snapshot: Option<Value>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalServiceModuleMetadata {
#[serde(default)]
name: Option<String>,
#[serde(default)]
deployment: Option<AdminServiceModuleDeploymentDto>,
#[serde(default, alias = "required_env")]
required_env: Vec<String>,
#[serde(default, alias = "status_path")]
status_path: Option<String>,
#[serde(default, alias = "status_url")]
status_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleServicesFile {
#[serde(default)]
modules: Vec<LocalModuleServiceState>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalServiceModuleHealthFile {
#[serde(default)]
records: Vec<AdminServiceModuleHealthCheckDto>,
#[serde(default = "default_service_module_health_version")]
version: u8,
}
const fn default_service_module_health_version() -> u8 {
1
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleServiceState {
module_name: String,
#[serde(default)]
services: Vec<LocalModuleServiceSpec>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalModuleServiceSpec {
name: String,
ready_url: String,
#[serde(default = "default_service_auto_start")]
auto_start: bool,
}
const fn default_service_auto_start() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConsolePackageInstallPlan {
#[serde(default)]
modules: Vec<LocalConsolePackageInstallPlanModule>,
#[serde(default = "default_console_package_install_plan_version")]
version: u8,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConsolePackageInstallPlanModule {
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
console_packages: Vec<LocalConsolePackageInstallPlanPackage>,
#[serde(default)]
manifest_reference: Option<String>,
module_name: String,
#[serde(default)]
restart_required: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConsolePackageInstallPlanPackage {
#[serde(default)]
command: Option<String>,
export_name: String,
#[serde(default)]
key: Option<String>,
package_name: String,
#[serde(default)]
requested_by_module: Option<String>,
#[serde(default)]
route: Option<String>,
#[serde(default)]
status: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalRuntimeConsoleBundleRegistry {
#[serde(default)]
bundles: Vec<LocalRuntimeConsoleBundle>,
#[serde(default = "default_console_package_install_plan_version")]
version: u8,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalRuntimeConsoleBundle {
entry: String,
export_name: String,
host_api: String,
#[serde(default)]
module_name: Option<String>,
package_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
required_capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
route: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
styles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
version: Option<String>,
}
#[derive(Clone, Debug)]
struct RuntimeConsoleBundleSpec {
bundle_url: String,
entry: String,
export_name: String,
host_api: String,
module_name: String,
package_name: String,
required_capabilities: Vec<String>,
route: Option<String>,
styles: Vec<RuntimeConsoleBundleStyleSpec>,
target_path: PathBuf,
version: Option<String>,
}
#[derive(Clone, Debug)]
struct RuntimeConsoleBundleStyleSpec {
entry: String,
source_url: String,
target_path: PathBuf,
}
#[derive(Debug)]
struct LocalConsolePackageInstallPlanState {
error: Option<String>,
exists: bool,
modules: HashMap<String, LocalConsolePackageInstallPlanModule>,
plan_file: String,
}
const fn default_module_catalog_version() -> u8 {
1
}
const fn default_console_package_install_plan_version() -> u8 {
1
}
impl AvailableModuleInstallStateContext {
fn from_paths(
metadata: &[AdminModuleMetadata],
env_file_path: impl AsRef<FsPath>,
console_plan_file_path: impl AsRef<FsPath>,
) -> Self {
Self {
console_plan: local_console_package_install_plan_state(console_plan_file_path),
linked_modules: local_linked_modules_env_state(&env_file_path),
registered_modules: metadata
.iter()
.map(|module| module.module_name.clone())
.collect(),
remote_sources: local_remote_modules_env_state(env_file_path),
running_base_urls: metadata
.iter()
.filter_map(|module| {
let Some(AdminModuleSourceDiagnostics::Remote(remote)) =
module.source_diagnostics.as_ref()
else {
return None;
};
Some((
module.module_name.clone(),
normalize_remote_base_url(&remote.base_url),
))
})
.collect(),
}
}
fn install_state(&self, module_name: &str) -> AdminModuleInstallStateDto {
self.install_state_for_source(module_name, ModuleSource::Remote)
}
fn install_state_for_source(
&self,
module_name: &str,
source: ModuleSource,
) -> AdminModuleInstallStateDto {
self.install_state_for_remote_source(module_name, source, module_name)
}
fn install_state_for_remote_source(
&self,
module_name: &str,
source: ModuleSource,
remote_source_name: &str,
) -> AdminModuleInstallStateDto {
AdminModuleInstallStateDto {
module_registered: self.registered_modules.contains(module_name),
linked_source: match source {
ModuleSource::Linked => Some(self.linked_source_state(module_name)),
_ => None,
},
remote_source: match source {
ModuleSource::Linked => None,
ModuleSource::Remote => Some(self.remote_source_state(remote_source_name)),
_ => Some(self.remote_source_state(remote_source_name)),
},
console_plan: self.console_plan_state(module_name),
}
}
fn linked_source_state(&self, module_name: &str) -> AdminModuleLinkedSourceInstallStateDto {
let desired_enabled = self.linked_modules.enabled.get(module_name).copied();
let running_enabled = self.registered_modules.contains(module_name);
let restart_reason = linked_source_restart_reason(desired_enabled, running_enabled);
AdminModuleLinkedSourceInstallStateDto {
env_file: self.linked_modules.env_file.clone(),
configured: desired_enabled.is_some(),
desired_enabled,
running_enabled,
restart_pending: restart_reason.is_some(),
restart_reason,
error: self.linked_modules.error.clone(),
}
}
fn remote_source_state(&self, module_name: &str) -> AdminModuleRemoteSourceInstallStateDto {
let desired_base_url = self.remote_sources.modules.get(module_name).cloned();
let running_base_url = self.running_base_urls.get(module_name).cloned();
let restart_reason =
remote_source_restart_reason(desired_base_url.as_deref(), running_base_url.as_deref());
AdminModuleRemoteSourceInstallStateDto {
env_file: self.remote_sources.env_file.clone(),
configured: desired_base_url.is_some(),
desired_base_url,
running_base_url,
restart_pending: restart_reason.is_some(),
restart_reason,
error: self.remote_sources.error.clone(),
}
}
fn reconcile_service_provider_remote_source(
&self,
module_name: &str,
remote_source: &mut AdminModuleRemoteSourceInstallStateDto,
) {
reconcile_service_provider_remote_source_base_url(
module_name,
self.running_base_urls.get(module_name).map(String::as_str),
remote_source,
);
}
fn console_plan_state(&self, module_name: &str) -> AdminModuleConsolePackagePlanStateDto {
let module_plan = self.console_plan.modules.get(module_name);
let packages = module_plan
.map(|module_plan| {
module_plan
.console_packages
.iter()
.map(|package| AdminModuleConsolePackagePlanPackageDto {
key: package.key.clone(),
package_name: package.package_name.clone(),
export_name: package.export_name.clone(),
command: package.command.clone(),
route: package.route.clone(),
status: package.status.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
AdminModuleConsolePackagePlanStateDto {
plan_file: self.console_plan.plan_file.clone(),
exists: self.console_plan.exists,
readable: self.console_plan.exists && self.console_plan.error.is_none(),
error: self.console_plan.error.clone(),
module_entry_present: module_plan.is_some(),
package_count: packages.len(),
restart_required: module_plan.and_then(|module_plan| module_plan.restart_required),
packages,
}
}
}
fn local_remote_modules_env_state(env_file_path: impl AsRef<FsPath>) -> LocalRemoteModulesEnvState {
let env_file_path = env_file_path.as_ref();
let env_file = env_file_path.display().to_string();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return LocalRemoteModulesEnvState {
env_file,
error: None,
keys: HashSet::new(),
modules: HashMap::new(),
};
}
Err(error) => {
return LocalRemoteModulesEnvState {
env_file,
error: Some(format!("remote module env file could not be read: {error}")),
keys: HashSet::new(),
modules: HashMap::new(),
};
}
};
LocalRemoteModulesEnvState {
env_file,
error: None,
keys: parse_env_keys_source(&source),
modules: parse_remote_modules_env_source(&source),
}
}
fn parse_env_keys_source(source: &str) -> HashSet<String> {
source
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, _) = line.split_once('=')?;
Some(key.trim().to_owned()).filter(|key| !key.is_empty())
})
.collect()
}
fn local_linked_modules_env_state(env_file_path: impl AsRef<FsPath>) -> LocalLinkedModulesEnvState {
let env_file_path = env_file_path.as_ref();
let env_file = env_file_path.display().to_string();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return LocalLinkedModulesEnvState {
env_file,
error: None,
enabled: HashMap::new(),
};
}
Err(error) => {
return LocalLinkedModulesEnvState {
env_file,
error: Some(format!("linked module env file could not be read: {error}")),
enabled: HashMap::new(),
};
}
};
LocalLinkedModulesEnvState {
env_file,
error: None,
enabled: parse_linked_modules_env_source(&source),
}
}
fn parse_linked_modules_env_source(source: &str) -> HashMap<String, bool> {
source
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
let module_name = module_name_from_linked_enabled_env_key(key.trim())?;
Some((
module_name,
parse_bool_env_value(&unquote_env_value(value.trim()))?,
))
})
.collect()
}
fn module_name_from_linked_enabled_env_key(key: &str) -> Option<String> {
Some(
key.strip_prefix("LENSO_MODULE_")?
.strip_suffix("_ENABLED")?
.to_ascii_lowercase()
.replace('_', "-"),
)
.filter(|name| !name.is_empty())
}
fn parse_bool_env_value(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn parse_remote_modules_env_source(source: &str) -> HashMap<String, String> {
source
.lines()
.filter_map(remote_modules_env_value)
.last()
.map(parse_remote_modules_value)
.unwrap_or_default()
}
fn remote_modules_env_value(line: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
(key.trim() == "REMOTE_MODULES").then(|| unquote_env_value(value.trim()).to_owned())
}
fn parse_remote_modules_value(value: String) -> HashMap<String, String> {
value
.split(',')
.filter_map(|entry| {
let (name, base_url) = entry.trim().split_once('=')?;
let name = name.trim();
let base_url = normalize_remote_base_url(base_url);
if name.is_empty() || base_url.is_empty() {
return None;
}
Some((name.to_owned(), base_url))
})
.collect()
}
fn unquote_env_value(value: &str) -> &str {
if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
&value[1..value.len() - 1]
} else {
value
}
}
fn local_console_package_install_plan_state(
console_registry_file_path: impl AsRef<FsPath>,
) -> LocalConsolePackageInstallPlanState {
let console_registry_file_path = console_registry_file_path.as_ref();
let plan_file = console_registry_file_path.display().to_string();
let source = match fs::read_to_string(console_registry_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return LocalConsolePackageInstallPlanState {
error: None,
exists: false,
modules: HashMap::new(),
plan_file,
};
}
Err(error) => {
return LocalConsolePackageInstallPlanState {
error: Some(format!(
"runtime console extension registry could not be read: {error}"
)),
exists: true,
modules: HashMap::new(),
plan_file,
};
}
};
match serde_json::from_str::<LocalRuntimeConsoleBundleRegistry>(&source) {
Ok(registry) => {
let mut modules: HashMap<String, LocalConsolePackageInstallPlanModule> = HashMap::new();
for bundle in registry.bundles {
let Some(module_name) = bundle.module_name else {
continue;
};
let key = format!("{}#{}", bundle.package_name, bundle.export_name);
let package = LocalConsolePackageInstallPlanPackage {
command: None,
export_name: bundle.export_name,
key: Some(key),
package_name: bundle.package_name,
requested_by_module: Some(module_name.clone()),
route: bundle.route,
status: Some("installed".to_owned()),
};
modules
.entry(module_name.clone())
.or_insert_with(|| LocalConsolePackageInstallPlanModule {
base_url: None,
console_packages: Vec::new(),
manifest_reference: None,
module_name,
restart_required: Some(true),
})
.console_packages
.push(package);
}
LocalConsolePackageInstallPlanState {
error: None,
exists: true,
modules,
plan_file,
}
}
Err(error) => LocalConsolePackageInstallPlanState {
error: Some(format!(
"runtime console extension registry could not be parsed: {error}"
)),
exists: true,
modules: HashMap::new(),
plan_file,
},
}
}
fn remote_source_restart_reason(
desired_base_url: Option<&str>,
running_base_url: Option<&str>,
) -> Option<String> {
match (desired_base_url, running_base_url) {
(Some(_), None) => {
Some("service provider source configured in .env but not loaded".to_owned())
}
(None, Some(_)) => {
Some("service provider source removed from .env but still loaded".to_owned())
}
(Some(desired), Some(running)) if desired != running => {
Some("REMOTE_MODULES base URL differs from loaded service provider metadata".to_owned())
}
_ => None,
}
}
fn linked_source_restart_reason(
desired_enabled: Option<bool>,
running_enabled: bool,
) -> Option<String> {
match (desired_enabled, running_enabled) {
(Some(true), false) => {
Some("linked module enabled by env override; restart API and worker".to_owned())
}
(Some(false), true) => {
Some("linked module disabled by env override; restart API and worker".to_owned())
}
_ => None,
}
}
fn normalize_remote_base_url(value: &str) -> String {
value.trim().trim_end_matches('/').to_owned()
}
fn module_catalog_file_response(
catalog_file_path: impl AsRef<FsPath>,
install_state: &AvailableModuleInstallStateContext,
) -> Option<AdminModuleRegistrySnapshotResponse> {
let catalog_file_path = catalog_file_path.as_ref();
let source = match fs::read_to_string(catalog_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => {
return Some(module_catalog_error_response(
catalog_file_path.display().to_string(),
format!("module catalog could not be read: {error}"),
));
}
};
Some(module_catalog_source_response(
catalog_file_path.display().to_string(),
&source,
install_state,
))
}
fn find_installable_catalog_entry(module_name: &str) -> Result<LocalModuleCatalogEntry, AppError> {
let catalog = read_install_catalog(PathBuf::from(".lenso/module-catalog.json"))?
.unwrap_or_else(official_module_catalog);
catalog
.modules
.into_iter()
.find(|entry| entry.name == module_name)
.ok_or_else(|| {
AppError::new(
ErrorCode::NotFound,
format!("available module {module_name} was not found"),
)
})
}
fn read_install_catalog(
catalog_file_path: impl AsRef<FsPath>,
) -> Result<Option<LocalModuleCatalog>, AppError> {
let catalog_file_path = catalog_file_path.as_ref();
let source = match fs::read_to_string(catalog_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("module catalog could not be read: {error}"),
));
}
};
serde_json::from_str::<LocalModuleCatalog>(&source)
.map(Some)
.map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("module catalog could not be parsed: {error}"),
)
})
}
fn official_module_catalog() -> LocalModuleCatalog {
serde_json::from_str::<LocalModuleCatalog>(OFFICIAL_MODULE_CATALOG_SOURCE)
.expect("official module catalog is valid")
}
fn module_catalog_source_response(
registry_file: String,
source: &str,
install_state: &AvailableModuleInstallStateContext,
) -> AdminModuleRegistrySnapshotResponse {
let catalog = match serde_json::from_str::<LocalModuleCatalog>(&source) {
Ok(catalog) => catalog,
Err(error) => {
return module_catalog_error_response(
registry_file,
format!("module catalog could not be parsed: {error}"),
);
}
};
let mut issues = Vec::new();
let modules = catalog
.modules
.into_iter()
.map(|entry| {
let module = module_catalog_entry_module(entry, install_state);
issues.extend(module_catalog_entry_issues(&module));
module
})
.collect::<Vec<_>>();
AdminModuleRegistrySnapshotResponse {
version: 1,
status: if issues.is_empty() {
AdminModuleRegistrySnapshotStatus::Passed
} else {
AdminModuleRegistrySnapshotStatus::Failed
},
catalog: AdminModuleRegistrySnapshotCatalogDto {
modules: modules.len(),
registry_file,
version: catalog.version,
},
issues,
modules,
}
}
fn validate_installable_catalog_entry(
entry: &LocalModuleCatalogEntry,
base_url: &str,
) -> Result<(), AppError> {
if entry.archived_at.is_some() {
return Err(AppError::new(
ErrorCode::Validation,
format!("available module {} is archived", entry.name),
));
}
if !matches!(entry.source.as_str(), "remote" | "service") {
return Err(AppError::new(
ErrorCode::Validation,
"only service catalog entries can be installed visually",
));
}
if base_url.is_empty() {
return Err(AppError::new(
ErrorCode::Validation,
format!("{} baseUrl is missing", entry.name),
));
}
if module_compatibility_issue(&entry.name, entry.compatibility.as_ref()).is_some() {
return Err(AppError::new(
ErrorCode::Validation,
format!("{} is not compatible with this Lenso host", entry.name),
));
}
Ok(())
}
fn install_base_url(entry: &LocalModuleCatalogEntry) -> Result<String, AppError> {
if let Some(base_url) = entry.base_url.as_ref() {
return Ok(normalize_remote_base_url(base_url));
}
if let Some(service_manifest) = entry
.provider
.as_ref()
.and_then(|provider| provider.service_manifest.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
&& is_http_manifest_reference(service_manifest)
{
return Ok(service_manifest
.strip_suffix("/manifest")
.unwrap_or(service_manifest)
.to_owned());
}
if is_http_manifest_reference(&entry.manifest_reference) {
return Ok(entry
.manifest_reference
.strip_suffix("/manifest")
.unwrap_or(&entry.manifest_reference)
.to_owned());
}
Err(AppError::new(
ErrorCode::Validation,
format!("{} baseUrl is missing", entry.name),
))
}
fn catalog_entry_remote_source_name(entry: &LocalModuleCatalogEntry) -> &str {
if entry.source == "service" {
return entry
.provided_by
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.or_else(|| {
entry
.provider
.as_ref()
.and_then(|provider| provider.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
})
.unwrap_or(&entry.name);
}
&entry.name
}
fn catalog_entry_install_state(
entry: &LocalModuleCatalogEntry,
install_state: &AvailableModuleInstallStateContext,
) -> AdminModuleInstallStateDto {
let source = catalog_entry_source(&entry.source);
let remote_source_name = catalog_entry_remote_source_name(entry);
let mut state =
install_state.install_state_for_remote_source(&entry.name, source, remote_source_name);
if entry.source == "service"
&& let Some(remote_source) = state.remote_source.as_mut()
{
install_state.reconcile_service_provider_remote_source(&entry.name, remote_source);
}
state
}
fn write_remote_modules_env(
env_file_path: impl AsRef<FsPath>,
module_name: &str,
base_url: &str,
) -> Result<(), AppError> {
let env_file_path = env_file_path.as_ref();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("remote module env file could not be read: {error}"),
));
}
};
let current_value = source
.lines()
.filter_map(remote_modules_env_value)
.last()
.unwrap_or_default();
let mut entries = parse_remote_modules_entries(¤t_value);
entries.retain(|entry| entry.name != module_name);
entries.push(RemoteModuleEnvEntry {
name: module_name.to_owned(),
base_url: normalize_remote_base_url(base_url),
});
let next_source = upsert_env_source(
&source,
"REMOTE_MODULES",
&format_remote_modules_entries(&entries),
);
fs::write(env_file_path, next_source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("remote module env file could not be written: {error}"),
)
})
}
fn remove_remote_modules_env(
env_file_path: impl AsRef<FsPath>,
module_name: &str,
) -> Result<(), AppError> {
let env_file_path = env_file_path.as_ref();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("remote module env file could not be read: {error}"),
));
}
};
let current_value = source
.lines()
.filter_map(remote_modules_env_value)
.last()
.unwrap_or_default();
let mut entries = parse_remote_modules_entries(¤t_value);
entries.retain(|entry| entry.name != module_name);
let next_source = if entries.is_empty() {
remove_env_key_source(&source, "REMOTE_MODULES")
} else {
upsert_env_source(
&source,
"REMOTE_MODULES",
&format_remote_modules_entries(&entries),
)
};
fs::write(env_file_path, next_source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("remote module env file could not be written: {error}"),
)
})
}
fn write_linked_module_profile_env(env_file_path: impl AsRef<FsPath>) -> Result<(), AppError> {
let env_file_path = env_file_path.as_ref();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("linked module env file could not be read: {error}"),
));
}
};
let next_source = upsert_env_source(&source, "LENSO_COMPOSITION_PROFILE", "demo");
fs::write(env_file_path, next_source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("linked module env file could not be written: {error}"),
)
})
}
fn write_linked_module_enabled_env(
env_file_path: impl AsRef<FsPath>,
module_name: &str,
enabled: bool,
) -> Result<(), AppError> {
let env_file_path = env_file_path.as_ref();
let source = match fs::read_to_string(env_file_path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("linked module env file could not be read: {error}"),
));
}
};
let key = linked_module_enabled_env_key(module_name);
let next_source = upsert_env_source(&source, &key, if enabled { "true" } else { "false" });
fs::write(env_file_path, next_source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("linked module env file could not be written: {error}"),
)
})
}
fn write_linked_runtime_console_extension_registry(
console_registry_file_path: impl AsRef<FsPath>,
entry: &LocalModuleCatalogEntry,
) -> Result<(), AppError> {
let console_registry_file_path = console_registry_file_path.as_ref();
if entry.console_packages.is_empty() {
return Ok(());
}
let mut registry = read_runtime_console_extension_registry(console_registry_file_path)?;
registry.version = 1;
registry.bundles.retain(|bundle| {
bundle.module_name.as_deref() != Some(entry.name.as_str())
&& !entry.console_packages.iter().any(|package| {
bundle.package_name == package.package_name
&& bundle.export_name == package.export_name
})
});
registry
.bundles
.extend(entry.console_packages.iter().map(|package| {
LocalRuntimeConsoleBundle {
entry: package
.entry
.clone()
.unwrap_or_else(|| linked_console_package_entry(&entry.name, package)),
export_name: package.export_name.clone(),
host_api: package
.host_api
.clone()
.unwrap_or_else(|| HOST_CONSOLE_PACKAGE_API_VERSION.to_owned()),
module_name: Some(entry.name.clone()),
package_name: package.package_name.clone(),
required_capabilities: package.required_capabilities.clone(),
route: package.route.clone(),
styles: package.styles.clone(),
version: package.version.clone(),
}
}));
write_runtime_console_extension_registry_file(console_registry_file_path, ®istry)
}
async fn write_linked_runtime_console_extensions(
console_registry_file_path: impl AsRef<FsPath>,
entry: &LocalModuleCatalogEntry,
) -> Result<(), AppError> {
if entry.console_packages.is_empty() {
return Ok(());
}
if entry
.console_packages
.iter()
.all(|package| package.bundle_url.is_some())
{
return write_runtime_console_extension_registry(
console_registry_file_path,
entry,
entry.base_url.as_deref().unwrap_or(""),
)
.await;
}
write_linked_runtime_console_extension_registry(console_registry_file_path, entry)
}
fn linked_console_package_entry(
module_name: &str,
package: &LocalModuleCatalogConsolePackage,
) -> String {
format!(
"{CONSOLE_EXTENSION_ROUTE_PREFIX}/{}/{}.js",
slugify(module_name),
slugify(&package.export_name)
)
}
async fn write_runtime_console_extension_registry(
console_registry_file_path: impl AsRef<FsPath>,
entry: &LocalModuleCatalogEntry,
base_url: &str,
) -> Result<(), AppError> {
let console_registry_file_path = console_registry_file_path.as_ref();
let specs = runtime_console_bundle_specs(console_registry_file_path, entry, base_url)?;
if specs.is_empty() {
return Ok(());
}
for spec in &specs {
let bytes = read_console_bundle_reference(&spec.bundle_url).await?;
if let Some(parent) = spec.target_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console extension bundle directory could not be created: {error}"),
)
})?;
}
fs::write(&spec.target_path, bytes).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console extension bundle could not be written: {error}"),
)
})?;
for style in &spec.styles {
let bytes = read_console_bundle_reference(&style.source_url).await?;
if let Some(parent) = style.target_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console extension style directory could not be created: {error}"),
)
})?;
}
fs::write(&style.target_path, bytes).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console extension style could not be written: {error}"),
)
})?;
}
}
let mut registry = read_runtime_console_extension_registry(console_registry_file_path)?;
registry.version = 1;
registry.bundles.retain(|bundle| {
bundle.module_name.as_deref() != Some(entry.name.as_str())
&& !specs.iter().any(|spec| {
bundle.package_name == spec.package_name && bundle.export_name == spec.export_name
})
});
registry
.bundles
.extend(specs.into_iter().map(|spec| LocalRuntimeConsoleBundle {
entry: spec.entry,
export_name: spec.export_name,
host_api: spec.host_api,
module_name: Some(spec.module_name),
package_name: spec.package_name,
required_capabilities: spec.required_capabilities,
route: spec.route,
styles: spec.styles.into_iter().map(|style| style.entry).collect(),
version: spec.version,
}));
write_runtime_console_extension_registry_file(console_registry_file_path, ®istry)
}
fn runtime_console_bundle_specs(
console_registry_file_path: &FsPath,
entry: &LocalModuleCatalogEntry,
base_url: &str,
) -> Result<Vec<RuntimeConsoleBundleSpec>, AppError> {
let module_slug = slugify(&entry.name);
entry
.console_packages
.iter()
.map(|package| {
let bundle_url = package.bundle_url.as_deref().ok_or_else(|| {
AppError::new(
ErrorCode::Validation,
format!(
"{} console package {}#{} bundleUrl is missing",
entry.name, package.package_name, package.export_name
),
)
})?;
let bundle_url = resolve_console_bundle_reference(bundle_url, base_url)?;
let file_name = console_bundle_file_name(&bundle_url, &package.export_name);
let target_path = console_registry_file_path
.parent()
.unwrap_or_else(|| FsPath::new(".lenso/console/extensions"))
.join(&module_slug)
.join(&file_name);
let styles = package
.styles
.iter()
.map(|style_reference| {
let source_url = resolve_console_bundle_reference(style_reference, base_url)?;
let file_name = console_style_file_name(&source_url, &package.export_name);
Ok(RuntimeConsoleBundleStyleSpec {
entry: format!(
"{CONSOLE_EXTENSION_ROUTE_PREFIX}/{module_slug}/{file_name}"
),
source_url,
target_path: console_registry_file_path
.parent()
.unwrap_or_else(|| FsPath::new(".lenso/console/extensions"))
.join(&module_slug)
.join(file_name),
})
})
.collect::<Result<Vec<_>, AppError>>()?;
Ok(RuntimeConsoleBundleSpec {
bundle_url,
entry: format!("{CONSOLE_EXTENSION_ROUTE_PREFIX}/{module_slug}/{file_name}"),
export_name: package.export_name.clone(),
host_api: package
.host_api
.clone()
.unwrap_or_else(|| HOST_CONSOLE_PACKAGE_API_VERSION.to_owned()),
module_name: entry.name.clone(),
package_name: package.package_name.clone(),
required_capabilities: package.required_capabilities.clone(),
route: package.route.clone(),
styles,
target_path,
version: package.version.clone(),
})
})
.collect()
}
fn resolve_console_bundle_reference(reference: &str, base_url: &str) -> Result<String, AppError> {
if reference.starts_with("http://")
|| reference.starts_with("https://")
|| reference.starts_with("file://")
{
return Ok(reference.to_owned());
}
let normalized_base = format!("{}/", base_url.trim_end_matches('/'));
let base = reqwest::Url::parse(&normalized_base).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("console bundle base URL could not be parsed: {error}"),
)
})?;
base.join(reference)
.map(|url| url.to_string())
.map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("console bundle URL could not be resolved: {error}"),
)
})
}
fn console_bundle_file_name(bundle_url: &str, export_name: &str) -> String {
console_asset_file_name(bundle_url, export_name, "js")
}
fn console_style_file_name(style_url: &str, export_name: &str) -> String {
console_asset_file_name(style_url, export_name, "css")
}
fn console_asset_file_name(asset_url: &str, export_name: &str, extension: &str) -> String {
reqwest::Url::parse(asset_url)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.filter(|segment| !segment.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| {
FsPath::new(asset_url)
.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| format!("{}.{}", slugify(export_name), extension))
}
async fn read_console_bundle_reference(reference: &str) -> Result<Vec<u8>, AppError> {
if reference.starts_with("http://") || reference.starts_with("https://") {
let response = reqwest::get(reference).await.map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console bundle could not be fetched: {error}"),
)
})?;
if !response.status().is_success() {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!(
"console bundle fetch failed: {} {}",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("")
),
));
}
return response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console bundle bytes could not be read: {error}"),
)
});
}
let path = if let Some(file_path) = reference.strip_prefix("file://") {
PathBuf::from(file_path)
} else {
PathBuf::from(reference)
};
fs::read(&path).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!(
"console bundle {} could not be read: {error}",
path.display()
),
)
})
}
fn read_runtime_console_extension_registry(
console_registry_file_path: &FsPath,
) -> Result<LocalRuntimeConsoleBundleRegistry, AppError> {
match fs::read_to_string(console_registry_file_path) {
Ok(source) => {
serde_json::from_str::<LocalRuntimeConsoleBundleRegistry>(&source).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("runtime console extension registry could not be parsed: {error}"),
)
})
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(LocalRuntimeConsoleBundleRegistry {
bundles: vec![],
version: 1,
})
}
Err(error) => Err(AppError::new(
ErrorCode::ExternalDependency,
format!("runtime console extension registry could not be read: {error}"),
)),
}
}
fn write_runtime_console_extension_registry_file(
console_registry_file_path: &FsPath,
registry: &LocalRuntimeConsoleBundleRegistry,
) -> Result<(), AppError> {
if let Some(parent) = console_registry_file_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!(
"runtime console extension registry directory could not be created: {error}"
),
)
})?;
}
let source = serde_json::to_string_pretty(registry)
.map(|source| format!("{source}\n"))
.map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!("runtime console extension registry could not be encoded: {error}"),
)
})?;
fs::write(console_registry_file_path, source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("runtime console extension registry could not be written: {error}"),
)
})
}
fn remove_console_package_install_plan_module(
console_plan_file_path: impl AsRef<FsPath>,
module_name: &str,
) -> Result<(), AppError> {
let console_plan_file_path = console_plan_file_path.as_ref();
let mut plan = match fs::read_to_string(console_plan_file_path) {
Ok(source) => {
serde_json::from_str::<LocalConsolePackageInstallPlan>(&source).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("console package install plan could not be parsed: {error}"),
)
})?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("console package install plan could not be read: {error}"),
));
}
};
plan.modules
.retain(|module| module.module_name != module_name);
let source = serde_json::to_string_pretty(&plan)
.map(|source| format!("{source}\n"))
.map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!("console package install plan could not be encoded: {error}"),
)
})?;
fs::write(console_plan_file_path, source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("console package install plan could not be written: {error}"),
)
})
}
fn remove_runtime_console_extension_registry_module(
console_registry_file_path: impl AsRef<FsPath>,
module_name: &str,
) -> Result<(), AppError> {
let console_registry_file_path = console_registry_file_path.as_ref();
let mut registry = match fs::read_to_string(console_registry_file_path) {
Ok(source) => {
serde_json::from_str::<LocalRuntimeConsoleBundleRegistry>(&source).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("runtime console extension registry could not be parsed: {error}"),
)
})?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("runtime console extension registry could not be read: {error}"),
));
}
};
registry
.bundles
.retain(|bundle| bundle.module_name.as_deref() != Some(module_name));
write_runtime_console_extension_registry_file(console_registry_file_path, ®istry)
}
fn remove_console_extension_module_dir(module_name: &str) -> Result<(), AppError> {
let path = PathBuf::from(".lenso/console/extensions").join(slugify(module_name));
match fs::remove_dir_all(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(AppError::new(
ErrorCode::ExternalDependency,
format!(
"runtime console extension directory {} could not be removed: {error}",
path.display()
),
)),
}
}
fn write_available_module_install_ledger(
ledger_path: impl AsRef<FsPath>,
entry: &LocalModuleCatalogEntry,
remote_source_name: &str,
base_url: &str,
) -> Result<(), AppError> {
let ledger_path = ledger_path.as_ref();
let ledger = read_module_install_ledger_value(ledger_path)?;
let receipt = available_module_install_ledger_entry(entry, remote_source_name, base_url);
let ledger = upsert_module_install_ledger_entry_value(ledger, receipt)?;
write_module_install_ledger_value(ledger_path, &ledger)
}
fn remove_module_install_ledger_module(
ledger_path: impl AsRef<FsPath>,
module_name: &str,
) -> Result<(), AppError> {
let ledger_path = ledger_path.as_ref();
let mut ledger = match fs::read_to_string(ledger_path) {
Ok(source) => serde_json::from_str::<Value>(&source).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("module install ledger could not be parsed: {error}"),
)
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(AppError::new(
ErrorCode::ExternalDependency,
format!("module install ledger could not be read: {error}"),
));
}
};
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| {
AppError::new(
ErrorCode::Validation,
"module install ledger modules must be an array",
)
})?;
let original_len = modules.len();
modules.retain(|module| module.get("moduleName").and_then(Value::as_str) != Some(module_name));
if modules.len() == original_len {
return Ok(());
}
let next = serde_json::json!({ "modules": modules.clone(), "version": 1 });
write_module_install_ledger_value(ledger_path, &next)
}
fn read_module_install_ledger_value(ledger_path: &FsPath) -> Result<Value, AppError> {
match fs::read_to_string(ledger_path) {
Ok(source) => serde_json::from_str::<Value>(&source).map_err(|error| {
AppError::new(
ErrorCode::Validation,
format!("module install ledger could not be parsed: {error}"),
)
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(serde_json::json!({ "modules": [], "version": 1 }))
}
Err(error) => Err(AppError::new(
ErrorCode::ExternalDependency,
format!("module install ledger could not be read: {error}"),
)),
}
}
fn upsert_module_install_ledger_entry_value(
mut ledger: Value,
receipt: Value,
) -> Result<Value, AppError> {
let module_name = receipt
.get("moduleName")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::new(
ErrorCode::Internal,
"module install ledger receipt moduleName is required",
)
})?
.to_owned();
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| {
AppError::new(
ErrorCode::Validation,
"module install ledger modules must be an array",
)
})?;
modules.retain(|module| {
module.get("moduleName").and_then(Value::as_str) != Some(module_name.as_str())
});
modules.push(receipt);
Ok(serde_json::json!({ "modules": modules.clone(), "version": 1 }))
}
fn write_module_install_ledger_value(ledger_path: &FsPath, ledger: &Value) -> Result<(), AppError> {
if let Some(parent) = ledger_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("module install ledger directory could not be created: {error}"),
)
})?;
}
let source = serde_json::to_string_pretty(ledger)
.map(|source| format!("{source}\n"))
.map_err(|error| {
AppError::new(
ErrorCode::Internal,
format!("module install ledger could not be encoded: {error}"),
)
})?;
fs::write(ledger_path, source).map_err(|error| {
AppError::new(
ErrorCode::ExternalDependency,
format!("module install ledger could not be written: {error}"),
)
})
}
fn available_module_install_ledger_entry(
entry: &LocalModuleCatalogEntry,
remote_source_name: &str,
base_url: &str,
) -> Value {
let base_url = normalize_remote_base_url(base_url);
let mut receipt = serde_json::json!({
"baseUrl": base_url,
"enabled": true,
"install": {
"commands": [],
"consolePackages": entry.console_packages.len(),
"env": [],
"services": []
},
"manifestReference": entry.manifest_reference,
"moduleName": entry.name,
"source": "remote",
"writes": available_module_install_writes(entry),
});
if entry.source == "service" || remote_source_name != entry.name {
receipt["service"] = serde_json::json!({
"baseUrl": base_url,
"manifestReference": entry
.service_manifest
.as_deref()
.or_else(|| {
entry
.provider
.as_ref()
.and_then(|provider| provider.service_manifest.as_deref())
})
.unwrap_or(&entry.manifest_reference),
"name": remote_source_name,
});
}
if let Some(compatibility) = &entry.compatibility
&& let Ok(compatibility) = serde_json::to_value(compatibility)
{
receipt["compatibility"] = compatibility;
}
if let Some(module_release) = module_release_receipt_from_catalog_entry(entry) {
receipt["moduleRelease"] = module_release;
}
receipt
}
fn available_module_install_writes(entry: &LocalModuleCatalogEntry) -> Vec<Value> {
let mut writes = vec![serde_json::json!({
"kind": "env",
"key": "REMOTE_MODULES",
"path": ".env",
})];
if !entry.console_packages.is_empty() {
writes.push(serde_json::json!({
"kind": "consoleExtensionRegistry",
"path": CONSOLE_EXTENSION_REGISTRY_PATH,
}));
}
writes
}
fn module_release_receipt_from_catalog_entry(entry: &LocalModuleCatalogEntry) -> Option<Value> {
Some(serde_json::json!({
"manifestReference": entry.manifest_reference,
"manifestSnapshot": module_release_snapshot_from_catalog_entry(entry)?,
}))
}
fn module_release_snapshot_from_catalog_entry(entry: &LocalModuleCatalogEntry) -> Option<Value> {
if entry.protocol.as_deref() != Some("lenso.module-release.v1") {
return None;
}
let mut snapshot = serde_json::json!({
"name": entry.name,
"protocol": "lenso.module-release.v1",
"source": entry.source,
"version": entry.version,
});
if let Some(provider) = entry.provider.as_ref() {
let provider_name = provider.name.as_deref()?.trim();
if provider_name.is_empty() {
return None;
}
let mut provider_snapshot = serde_json::json!({ "name": provider_name });
if let Some(service_package) = provider
.service_package
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
provider_snapshot["servicePackage"] = serde_json::json!(service_package);
}
if let Some(service_manifest) = provider
.service_manifest
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
provider_snapshot["serviceManifest"] = serde_json::json!(service_manifest);
}
snapshot["provider"] = provider_snapshot;
}
if let Some(summary) = entry.summary.as_deref() {
snapshot["summary"] = serde_json::json!(summary);
}
if let Some(base_url) = entry.base_url.as_deref() {
snapshot["baseUrl"] = serde_json::json!(base_url);
}
if !entry.capabilities.is_empty() {
snapshot["capabilities"] = serde_json::json!(entry.capabilities);
}
Some(snapshot)
}
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut previous_dash = false;
for character in value.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
previous_dash = false;
} else if !previous_dash {
slug.push('-');
previous_dash = true;
}
}
slug.trim_matches('-').to_owned()
}
#[derive(Debug)]
struct RemoteModuleEnvEntry {
name: String,
base_url: String,
}
fn parse_remote_modules_entries(value: &str) -> Vec<RemoteModuleEnvEntry> {
value
.split(',')
.filter_map(|entry| {
let (name, base_url) = entry.trim().split_once('=')?;
let name = name.trim();
let base_url = normalize_remote_base_url(base_url);
if name.is_empty() || base_url.is_empty() {
return None;
}
Some(RemoteModuleEnvEntry {
name: name.to_owned(),
base_url,
})
})
.collect()
}
fn format_remote_modules_entries(entries: &[RemoteModuleEnvEntry]) -> String {
entries
.iter()
.map(|entry| format!("{}={}", entry.name, entry.base_url))
.collect::<Vec<_>>()
.join(",")
}
fn upsert_env_source(source: &str, key: &str, value: &str) -> String {
let mut replaced = false;
let mut lines = Vec::new();
for line in source.lines() {
if env_value(line, key).is_some() {
if !replaced {
lines.push(format!("{key}={value}"));
replaced = true;
}
} else {
lines.push(line.to_owned());
}
}
if !replaced {
lines.push(format!("{key}={value}"));
}
format!("{}\n", lines.join("\n"))
}
fn remove_env_key_source(source: &str, key: &str) -> String {
let lines = source
.lines()
.filter(|line| env_value(line, key).is_none())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn env_value(line: &str, expected_key: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
(key.trim() == expected_key).then(|| unquote_env_value(value.trim()).to_owned())
}
fn linked_module_enabled_env_key(module_name: &str) -> String {
format!(
"LENSO_MODULE_{}_ENABLED",
module_name.replace('-', "_").to_ascii_uppercase()
)
}
fn module_catalog_error_response(
registry_file: String,
message: String,
) -> AdminModuleRegistrySnapshotResponse {
AdminModuleRegistrySnapshotResponse {
version: 1,
status: AdminModuleRegistrySnapshotStatus::Failed,
catalog: AdminModuleRegistrySnapshotCatalogDto {
modules: 0,
registry_file,
version: 1,
},
issues: vec![AdminModuleRegistrySnapshotIssueDto {
group: "Catalog".to_owned(),
message,
fix: "fix .lenso/module-catalog.json or remove it to use loaded remote modules"
.to_owned(),
}],
modules: vec![],
}
}
fn module_catalog_entry_module(
entry: LocalModuleCatalogEntry,
install_state: &AvailableModuleInstallStateContext,
) -> AdminModuleRegistrySnapshotModuleDto {
let source = catalog_entry_source(&entry.source);
let state = catalog_entry_install_state(&entry, install_state);
let archived = entry.archived_at.is_some();
let module_release = module_release_from_catalog_entry(&entry);
let name = entry.name;
let needs_attention = !archived
&& ((matches!(source, ModuleSource::Remote)
&& module_needs_base_url(entry.base_url.as_deref(), &entry.manifest_reference))
|| module_compatibility_issue(&name, entry.compatibility.as_ref()).is_some());
AdminModuleRegistrySnapshotModuleDto {
name: name.clone(),
source,
catalog_version: entry.version.clone(),
manifest_reference: entry.manifest_reference,
provided_by: entry.provided_by,
service_manifest: entry.service_manifest,
module_release,
summary: entry.summary,
base_url: entry.base_url,
capabilities: entry.capabilities,
console_package_hints: entry.console_packages.len(),
compatibility: entry.compatibility,
host_compatibility: host_module_compatibility(),
archived_at: entry.archived_at,
archive_reason: entry.archive_reason,
manifest_name: if archived { None } else { Some(name.clone()) },
manifest_status: if archived {
AdminModuleRegistrySnapshotManifestStatus::Archived
} else {
AdminModuleRegistrySnapshotManifestStatus::Ok
},
manifest_version: if archived { None } else { Some(entry.version) },
install_state: state,
status: if archived {
AdminModuleRegistrySnapshotModuleStatus::Archived
} else if needs_attention {
AdminModuleRegistrySnapshotModuleStatus::NeedsAttention
} else {
AdminModuleRegistrySnapshotModuleStatus::Ready
},
}
}
fn module_release_from_catalog_entry(
entry: &LocalModuleCatalogEntry,
) -> Option<AdminModuleReleaseDto> {
(entry.protocol.as_deref() == Some("lenso.module-release.v1")).then(|| AdminModuleReleaseDto {
manifest_reference: entry.manifest_reference.clone(),
name: Some(entry.name.clone()),
version: Some(entry.version.clone()),
source: Some(entry.source.clone()),
provider_name: entry
.provider
.as_ref()
.and_then(|provider| provider.name.clone()),
service_package: entry
.provider
.as_ref()
.and_then(|provider| provider.service_package.clone()),
service_manifest: entry
.provider
.as_ref()
.and_then(|provider| provider.service_manifest.clone()),
})
}
fn module_release_from_receipt(
receipt: &LocalModuleInstallLedgerModule,
) -> Option<AdminModuleReleaseDto> {
let module_release = receipt.module_release.as_ref()?;
let snapshot = module_release.manifest_snapshot.as_ref();
let provider = snapshot
.and_then(|snapshot| snapshot.get("provider"))
.and_then(Value::as_object);
Some(AdminModuleReleaseDto {
manifest_reference: module_release.manifest_reference.clone()?,
name: snapshot
.and_then(|snapshot| snapshot.get("name"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
version: snapshot
.and_then(|snapshot| snapshot.get("version"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
source: snapshot
.and_then(|snapshot| snapshot.get("source"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| receipt.source.clone()),
provider_name: provider
.and_then(|provider| provider.get("name"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
service_package: provider
.and_then(|provider| {
provider
.get("servicePackage")
.or_else(|| provider.get("service_package"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
service_manifest: provider
.and_then(|provider| {
provider
.get("serviceManifest")
.or_else(|| provider.get("service_manifest"))
})
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
fn catalog_entry_source(source: &str) -> ModuleSource {
if source == "linked" {
ModuleSource::Linked
} else {
ModuleSource::Remote
}
}
fn module_catalog_entry_issues(
module: &AdminModuleRegistrySnapshotModuleDto,
) -> Vec<AdminModuleRegistrySnapshotIssueDto> {
if matches!(
module.status,
AdminModuleRegistrySnapshotModuleStatus::Archived
) {
return vec![];
}
let mut issues = Vec::new();
if let Some(issue) = module_compatibility_issue(&module.name, module.compatibility.as_ref()) {
issues.push(issue);
}
if matches!(module.source, ModuleSource::Remote)
&& module_needs_base_url(module.base_url.as_deref(), &module.manifest_reference)
{
issues.push(AdminModuleRegistrySnapshotIssueDto {
group: "Catalog".to_owned(),
message: format!("{} baseUrl is missing", module.name),
fix: "add baseUrl or use a manifest URL ending with /manifest".to_owned(),
});
}
issues
}
fn module_needs_base_url(base_url: Option<&str>, manifest_reference: &str) -> bool {
base_url.is_none() && !is_http_manifest_reference(manifest_reference)
}
fn is_http_manifest_reference(manifest_reference: &str) -> bool {
(manifest_reference.starts_with("http://") || manifest_reference.starts_with("https://"))
&& manifest_reference.ends_with("/manifest")
}
fn module_compatibility_issue(
module_name: &str,
compatibility: Option<&AdminModuleCompatibilityDto>,
) -> Option<AdminModuleRegistrySnapshotIssueDto> {
let compatibility = compatibility?;
if let Some(lenso) = compatibility.lenso.as_ref() {
if let Some(min_version) = lenso.min_version.as_ref() {
if !matches!(
compare_versions(HOST_LENSO_VERSION, min_version),
Some(Ordering::Equal | Ordering::Greater)
) {
return Some(AdminModuleRegistrySnapshotIssueDto {
group: "Compatibility".to_owned(),
message: format!(
"{module_name} requires Lenso >= {min_version}; host is {HOST_LENSO_VERSION}"
),
fix: format!(
"upgrade Lenso to {min_version} or install a compatible {module_name} catalog entry"
),
});
}
}
if let Some(max_version) = lenso.max_version.as_ref() {
if !matches!(
compare_versions(HOST_LENSO_VERSION, max_version),
Some(Ordering::Equal | Ordering::Less)
) {
return Some(AdminModuleRegistrySnapshotIssueDto {
group: "Compatibility".to_owned(),
message: format!(
"{module_name} supports Lenso <= {max_version}; host is {HOST_LENSO_VERSION}"
),
fix: format!("install a compatible {module_name} catalog entry"),
});
}
}
}
if let Some(console_package_api) = compatibility.console_package_api.as_ref() {
if console_package_api != HOST_CONSOLE_PACKAGE_API_VERSION {
return Some(AdminModuleRegistrySnapshotIssueDto {
group: "Compatibility".to_owned(),
message: format!(
"{module_name} requires console package API {console_package_api}; host supports {HOST_CONSOLE_PACKAGE_API_VERSION}"
),
fix: format!(
"install a compatible {module_name} catalog entry or update Runtime Console package support"
),
});
}
}
if let Some(remote_protocol_version) = compatibility.remote_protocol_version.as_ref() {
if remote_protocol_version != HOST_REMOTE_PROTOCOL_VERSION {
return Some(AdminModuleRegistrySnapshotIssueDto {
group: "Compatibility".to_owned(),
message: format!(
"{module_name} requires remote protocol {remote_protocol_version}; host supports {HOST_REMOTE_PROTOCOL_VERSION}"
),
fix: format!("install a compatible {module_name} service release"),
});
}
}
if let Some(feature) = compatibility
.required_host_features
.iter()
.find(|feature| !SUPPORTED_SERVICE_MODULE_FEATURES.contains(&feature.as_str()))
{
return Some(AdminModuleRegistrySnapshotIssueDto {
group: "Compatibility".to_owned(),
message: format!("{module_name} requires unsupported host feature {feature}"),
fix: format!("upgrade Lenso or install a compatible {module_name} service release"),
});
}
None
}
fn compare_versions(left: &str, right: &str) -> Option<Ordering> {
Some(parse_version(left)?.cmp(&parse_version(right)?))
}
fn parse_version(value: &str) -> Option<[u64; 3]> {
let mut parts = value.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next()?.parse::<u64>().ok()?;
if parts.next().is_some() {
return None;
}
Some([major, minor, patch])
}
fn host_module_compatibility() -> AdminModuleHostCompatibilityDto {
AdminModuleHostCompatibilityDto {
console_package_api: HOST_CONSOLE_PACKAGE_API_VERSION.to_owned(),
lenso_version: HOST_LENSO_VERSION.to_owned(),
}
}
fn module_registry_snapshot_module(
module: AdminModuleMetadata,
install_state: &AvailableModuleInstallStateContext,
) -> AdminModuleRegistrySnapshotModuleDto {
let module_name = module.module_name;
let capabilities = module.capabilities;
let console_package_hints = module.console.len();
let remote = match module.source_diagnostics {
Some(AdminModuleSourceDiagnostics::Remote(remote)) => Some(remote),
None => None,
};
let manifest_reference = remote
.as_ref()
.map(|diagnostics| diagnostics.manifest_url.clone())
.unwrap_or_else(|| "-".to_owned());
let base_url = remote
.as_ref()
.map(|diagnostics| diagnostics.base_url.clone());
let has_error = !matches!(module.load_status, ModuleLoadStatus::Loaded)
|| remote
.as_ref()
.and_then(|diagnostics| diagnostics.last_load_error.as_ref())
.is_some();
AdminModuleRegistrySnapshotModuleDto {
name: module_name.clone(),
source: module.source,
catalog_version: "unknown".to_owned(),
manifest_reference,
provided_by: None,
service_manifest: None,
module_release: None,
summary: None,
base_url,
capabilities,
console_package_hints,
compatibility: None,
host_compatibility: host_module_compatibility(),
archived_at: None,
archive_reason: None,
manifest_name: if has_error {
None
} else {
Some(module_name.clone())
},
manifest_status: if has_error {
AdminModuleRegistrySnapshotManifestStatus::Unreadable
} else {
AdminModuleRegistrySnapshotManifestStatus::Ok
},
manifest_version: if has_error {
None
} else {
Some("unknown".to_owned())
},
install_state: install_state.install_state(&module_name),
status: if has_error {
AdminModuleRegistrySnapshotModuleStatus::NeedsAttention
} else {
AdminModuleRegistrySnapshotModuleStatus::Ready
},
}
}
fn source_diagnostics_dto(
diagnostics: Option<AdminModuleSourceDiagnostics>,
) -> Option<AdminModuleSourceDiagnosticsDto> {
match diagnostics? {
AdminModuleSourceDiagnostics::Remote(remote) => Some(
AdminModuleSourceDiagnosticsDto::Remote(AdminRemoteModuleDiagnosticsDto {
transport: remote.transport,
base_url: remote.base_url,
manifest_url: remote.manifest_url,
timeout_ms: remote.timeout_ms,
auth_configured: remote.auth_configured,
load_duration_ms: remote.load_duration_ms,
last_checked_at: remote.last_checked_at,
last_load_error: remote.last_load_error,
}),
),
}
}
fn module_governance(
module: &AdminModuleMetadata,
manifest_lints: &[ModuleManifestLint],
) -> AdminModuleGovernanceDto {
let references = module_capability_references(
module.admin.as_ref(),
&module.http_routes,
module.lifecycle.as_ref(),
&module.console,
&module.console_contributions,
);
let declared = module
.capabilities
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let referenced = references
.iter()
.map(|reference| reference.capability.as_str())
.collect::<HashSet<_>>();
let capability_issues = references
.iter()
.filter(|reference| !declared.contains(reference.capability.as_str()))
.map(|reference| AdminCapabilityIssueDto {
capability: reference.capability.clone(),
subject: format!("capability.reference.{}", reference.subject),
message: "Capability reference is not declared by the module.".to_owned(),
suggestion: format!(
"Add `{}` to ModuleManifest.capabilities or update the reference.",
reference.capability
),
})
.collect::<Vec<_>>();
let unused_count = declared
.iter()
.filter(|capability| !referenced.contains(**capability))
.count();
AdminModuleGovernanceDto {
activation_state: module_activation_state(&module.load_status, manifest_lints),
activation_reasons: module_activation_reasons(&module.load_status, manifest_lints),
capability_summary: AdminCapabilitySummaryDto {
declared_count: declared.len(),
referenced_count: referenced.len(),
missing_count: capability_issues.len(),
unused_count,
},
capability_issues,
}
}
fn module_activation_state(
load_status: &ModuleLoadStatus,
manifest_lints: &[ModuleManifestLint],
) -> AdminModuleActivationState {
if matches!(load_status, ModuleLoadStatus::Error { .. })
|| manifest_lints
.iter()
.any(|lint| lint.severity == ModuleManifestLintSeverity::Error)
{
return AdminModuleActivationState::Blocked;
}
if manifest_lints
.iter()
.any(|lint| lint.severity == ModuleManifestLintSeverity::Warning)
{
return AdminModuleActivationState::NeedsAttention;
}
AdminModuleActivationState::Active
}
fn module_activation_reasons(
load_status: &ModuleLoadStatus,
manifest_lints: &[ModuleManifestLint],
) -> Vec<String> {
let state = module_activation_state(load_status, manifest_lints);
let mut reasons = Vec::new();
if let ModuleLoadStatus::Error { message } = load_status {
reasons.push(format!("module failed to load: {message}"));
}
let severity = match state {
AdminModuleActivationState::Blocked => ModuleManifestLintSeverity::Error,
AdminModuleActivationState::NeedsAttention => ModuleManifestLintSeverity::Warning,
AdminModuleActivationState::Active => return reasons,
};
reasons.extend(
manifest_lints
.iter()
.filter(|lint| lint.severity == severity)
.map(|lint| format!("{}: {}", lint.subject, lint.message)),
);
reasons
}
fn metadata_response(
snapshot: crate::AdminModuleMetadataSnapshot,
) -> AdminModuleMetadataListResponse {
AdminModuleMetadataListResponse {
modules: metadata_response_modules(snapshot.modules),
refreshed_at: snapshot.refreshed_at,
refresh_error: snapshot.refresh_error,
refresh_history: snapshot
.refresh_history
.into_iter()
.map(refresh_record_dto)
.collect(),
}
}
fn refresh_record_dto(record: AdminModuleMetadataRefreshRecord) -> AdminModuleRefreshRecordDto {
AdminModuleRefreshRecordDto {
id: record.id,
status: match record.status {
AdminModuleMetadataRefreshStatus::Success => AdminModuleRefreshStatusDto::Success,
AdminModuleMetadataRefreshStatus::Error => AdminModuleRefreshStatusDto::Error,
},
started_at: record.started_at,
completed_at: record.completed_at,
duration_ms: record.duration_ms,
module_count: record.module_count,
error: record.error,
module_results: record
.module_results
.into_iter()
.map(refresh_module_result_dto)
.collect(),
}
}
fn refresh_module_result_dto(
result: AdminModuleMetadataRefreshModuleResult,
) -> AdminModuleRefreshModuleResultDto {
AdminModuleRefreshModuleResultDto {
module_name: result.module_name,
source: result.source,
status: match result.status {
AdminModuleMetadataRefreshModuleStatus::Loaded => {
AdminModuleRefreshModuleStatusDto::Loaded
}
AdminModuleMetadataRefreshModuleStatus::Error => {
AdminModuleRefreshModuleStatusDto::Error
}
},
duration_ms: result.duration_ms,
endpoint: result.endpoint,
error: result.error,
}
}
fn schema_response_modules(modules: Vec<AdminModule>) -> Vec<AdminModuleSchema> {
modules
.iter()
.filter(|m| m.listed_in_schema)
.map(|m| AdminModuleSchema {
module_name: m.module_name.clone(),
source: m.source,
status: admin_module_status(&m.load_status),
error: load_error_message(&m.load_status),
schema: m.schema.clone(),
})
.collect()
}
fn admin_module_status(status: &ModuleLoadStatus) -> AdminModuleStatus {
match status {
ModuleLoadStatus::Loaded => AdminModuleStatus::Loaded,
ModuleLoadStatus::Error { .. } => AdminModuleStatus::Error,
}
}
fn load_error_message(status: &ModuleLoadStatus) -> Option<String> {
match status {
ModuleLoadStatus::Loaded => None,
ModuleLoadStatus::Error { message } => Some(message.clone()),
}
}
#[utoipa::path(
post,
path = "/admin/data/{module}/actions/{action}",
operation_id = "admin_data_invoke_action",
tag = "admin-data",
params(
("module" = String, Path, description = "Module name, e.g. remote-crm"),
("action" = String, Path, description = "Declared admin action name"),
("authorization" = String, Header, description = "Development service bearer token"),
),
request_body = AdminActionInvokeRequest,
responses(
(status = 200, description = "Action result", body = AdminActionInvokeResponse, content_type = "application/json"),
(status = 400, description = "Request validation failed", body = ErrorResponse, content_type = "application/problem+json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown module or undeclared action", body = ErrorResponse, content_type = "application/problem+json"),
(status = 502, description = "Action source is unavailable or failed", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn invoke_action(
admin: AdminActor,
State(ctx): State<AppContext>,
HttpRequestContext(request_ctx): HttpRequestContext,
Path((module, action)): Path<(String, String)>,
Json(request): Json<AdminActionInvokeRequest>,
) -> Result<Json<AdminActionInvokeResponse>, ApiErrorResponse> {
let started_at = ctx.clock.now();
let started = Instant::now();
let admin_module = find_loaded_action_module(&module, &request_ctx)?;
let declaration = declared_action(&admin_module, &action, &request_ctx)?;
ensure_action_capability(&admin, &declaration.capability, &request_ctx)?;
ensure_action_confirmation(&request, &declaration, &request_ctx)?;
ensure_action_input(&request, &declaration, &request_ctx)?;
let action_source = admin_module.action_source.as_ref().ok_or_else(|| {
ApiErrorResponse::with_context(
AppError::new(
ErrorCode::ExternalDependency,
format!("module {module} has no admin action source"),
)
.retryable(),
&request_ctx,
)
})?;
let input = request.input;
let result = action_source.invoke(&action, input.clone()).await;
match result {
Ok(data) => {
record_admin_action_story(
&ctx,
&request_ctx,
AdminActionStoryRecord {
action_name: action,
capability: declaration.capability,
duration_ms: elapsed_ms(started),
error_code: None,
error_message: None,
input,
label: declaration.label,
module_name: module,
result: Some(data.clone()),
started_at,
success: true,
},
)
.await;
Ok(Json(AdminActionInvokeResponse {
data,
invocation: action_invocation_dto(&request_ctx),
}))
}
Err(error) => {
let error_code = error.code.as_str().to_owned();
let error_message = error.public_message.clone();
record_admin_action_story(
&ctx,
&request_ctx,
AdminActionStoryRecord {
action_name: action,
capability: declaration.capability,
duration_ms: elapsed_ms(started),
error_code: Some(error_code),
error_message: Some(error_message),
input,
label: declaration.label,
module_name: module,
result: None,
started_at,
success: false,
},
)
.await;
Err(ApiErrorResponse::with_context(error, &request_ctx))
}
}
}
#[utoipa::path(
get,
path = "/admin/data/{module}/queries/{query}",
operation_id = "admin_data_query_value",
tag = "admin-data",
params(
("module" = String, Path, description = "Module name, e.g. remote-crm"),
("query" = String, Path, description = "Declared admin query name"),
("authorization" = String, Header, description = "Development service bearer token"),
),
responses(
(status = 200, description = "Query result", body = AdminQueryResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown module or undeclared query", body = ErrorResponse, content_type = "application/problem+json"),
(status = 502, description = "Query source is unavailable or failed", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn query_value(
admin: AdminActor,
HttpRequestContext(request_ctx): HttpRequestContext,
Path((module, query)): Path<(String, String)>,
) -> Result<Json<AdminQueryResponse>, ApiErrorResponse> {
let admin_module = find_loaded_query_module(&module, &request_ctx)?;
let declaration = declared_query(&admin_module, &query, &request_ctx)?;
ensure_query_capability(&admin, &declaration.capability, &request_ctx)?;
let query_source = admin_module.query_source.as_ref().ok_or_else(|| {
ApiErrorResponse::with_context(
AppError::new(
ErrorCode::ExternalDependency,
format!("module {module} has no admin query source"),
)
.retryable(),
&request_ctx,
)
})?;
let data = query_source
.query(&query)
.await
.map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
Ok(Json(AdminQueryResponse { data }))
}
#[derive(Debug, Clone)]
struct DeclaredAction {
label: String,
capability: String,
input_schema: Option<AdminActionInputSchema>,
confirmation: Option<AdminActionConfirmation>,
}
fn declared_action(
module: &AdminModule,
action: &str,
ctx: &RequestContext,
) -> Result<DeclaredAction, ApiErrorResponse> {
let Some(AdminSurface::DeclarativeCustom(surface)) = module.admin.as_ref() else {
return Err(ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, format!("unknown action: {action}")),
ctx,
));
};
surface
.actions
.iter()
.find(|declared| declared.name == action)
.map(|declared| DeclaredAction {
capability: declared.capability.clone(),
confirmation: declared.confirmation.clone(),
input_schema: declared.input_schema.clone(),
label: declared.label.clone(),
})
.ok_or_else(|| {
ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, format!("unknown action: {action}")),
ctx,
)
})
}
#[derive(Debug, Clone)]
struct DeclaredQuery {
capability: String,
}
fn declared_query(
module: &AdminModule,
query: &str,
ctx: &RequestContext,
) -> Result<DeclaredQuery, ApiErrorResponse> {
let Some(AdminSurface::DeclarativeCustom(surface)) = module.admin.as_ref() else {
return Err(ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, format!("unknown query: {query}")),
ctx,
));
};
for page in &surface.pages {
for section in &page.sections {
let AdminDeclarativeComponent::QueryValue {
capability,
query: declared,
..
} = §ion.component
else {
continue;
};
if declared == query {
return Ok(DeclaredQuery {
capability: capability.clone(),
});
}
}
}
Err(ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, format!("unknown query: {query}")),
ctx,
))
}
fn ensure_action_input(
request: &AdminActionInvokeRequest,
declaration: &DeclaredAction,
ctx: &RequestContext,
) -> Result<(), ApiErrorResponse> {
let Some(input_schema) = declaration.input_schema.as_ref() else {
return Ok(());
};
if input_schema.fields.is_empty() {
return Ok(());
}
let Some(input) = request.input.as_object() else {
return Err(input_validation_error(
"admin action input must be a JSON object",
ctx,
));
};
let declared_fields = input_schema
.fields
.iter()
.map(|field| field.name.as_str())
.collect::<HashSet<_>>();
if let Some(field_name) = input
.keys()
.find(|field_name| !declared_fields.contains(field_name.as_str()))
{
return Err(input_validation_error(
format!("admin action input field `{field_name}` is not declared"),
ctx,
));
}
for field in &input_schema.fields {
let value = input.get(&field.name);
if field.required && matches!(value, None | Some(Value::Null)) {
return Err(input_validation_error(
format!("admin action input field `{}` is required", field.name),
ctx,
));
}
let Some(value) = value.filter(|value| !value.is_null()) else {
continue;
};
if !action_input_field_type_matches(field, value) {
return Err(input_validation_error(
format!(
"admin action input field `{}` must be {}",
field.name,
action_input_field_type_label(&field.field_type)
),
ctx,
));
}
}
Ok(())
}
fn action_input_field_type_matches(field: &AdminActionInputField, value: &Value) -> bool {
match &field.field_type {
FieldType::String | FieldType::Timestamp => value.is_string(),
FieldType::Integer => value
.as_number()
.is_some_and(|number| number.as_i64().is_some() || number.as_u64().is_some()),
FieldType::Boolean => value.is_boolean(),
FieldType::Json => true,
_ => true,
}
}
fn action_input_field_type_label(field_type: &FieldType) -> &'static str {
match field_type {
FieldType::String => "a string",
FieldType::Integer => "an integer",
FieldType::Boolean => "a boolean",
FieldType::Timestamp => "a timestamp string",
FieldType::Json => "valid JSON",
_ => "the declared type",
}
}
fn input_validation_error(message: impl Into<String>, ctx: &RequestContext) -> ApiErrorResponse {
ApiErrorResponse::with_context(AppError::new(ErrorCode::Validation, message), ctx)
}
fn action_invocation_dto(request_ctx: &RequestContext) -> AdminActionInvocationDto {
AdminActionInvocationDto {
request_id: request_ctx.request_id.0.clone(),
correlation_id: request_ctx.correlation_id.0.clone(),
story_node_id: admin_action_story_event_id(request_ctx),
}
}
fn ensure_action_confirmation(
request: &AdminActionInvokeRequest,
declaration: &DeclaredAction,
ctx: &RequestContext,
) -> Result<(), ApiErrorResponse> {
let Some(required_phrase) = declaration
.confirmation
.as_ref()
.and_then(|confirmation| confirmation.required_phrase.as_deref())
.filter(|phrase| !phrase.is_empty())
else {
return Ok(());
};
if request.confirmation_phrase.as_deref() == Some(required_phrase) {
return Ok(());
}
Err(ApiErrorResponse::with_context(
AppError::new(
ErrorCode::Validation,
"admin action confirmation phrase did not match",
),
ctx,
))
}
fn ensure_action_capability(
admin: &AdminActor,
capability: &str,
ctx: &RequestContext,
) -> Result<(), ApiErrorResponse> {
match admin {
AdminActor::System => Ok(()),
AdminActor::Service { scopes, .. } | AdminActor::User { scopes, .. }
if scopes.iter().any(|scope| scope == capability) =>
{
Ok(())
}
AdminActor::Service { .. } | AdminActor::User { .. } => {
Err(ApiErrorResponse::with_context(
AppError::new(
ErrorCode::Forbidden,
format!("missing admin action capability: {capability}"),
),
ctx,
))
}
}
}
fn ensure_query_capability(
admin: &AdminActor,
capability: &str,
ctx: &RequestContext,
) -> Result<(), ApiErrorResponse> {
match admin {
AdminActor::System => Ok(()),
AdminActor::Service { scopes, .. } | AdminActor::User { scopes, .. }
if scopes.iter().any(|scope| scope == capability) =>
{
Ok(())
}
AdminActor::Service { .. } | AdminActor::User { .. } => {
Err(ApiErrorResponse::with_context(
AppError::new(
ErrorCode::Forbidden,
format!("missing admin query capability: {capability}"),
),
ctx,
))
}
}
}
async fn record_admin_action_story(
ctx: &AppContext,
request_ctx: &RequestContext,
record: AdminActionStoryRecord,
) {
let module_name = record.module_name.clone();
let action_name = record.action_name.clone();
let success = record.success;
if let Err(error) = insert_admin_action_story_event(&ctx.db, request_ctx, record).await {
tracing::warn!(
error = ?error,
module_name = %module_name,
action_name = %action_name,
success,
request_id = %request_ctx.request_id.0,
correlation_id = %request_ctx.correlation_id.0,
"failed to persist admin action story event"
);
}
}
fn elapsed_ms(started: Instant) -> i64 {
started.elapsed().as_millis().min(i64::MAX as u128) as i64
}
#[cfg(test)]
mod tests {
use super::*;
use platform_module::{
AdminSchema, AdminSurface, ConsoleActionInputBinding, ConsoleActionInputValue, ConsoleArea,
ConsoleContribution, ConsoleContributionAction, ConsolePackage, ConsoleSurface,
EntitySchema, LifecycleActivationJobDeclaration, LifecycleActivationRunPolicy,
LifecycleSurface, ModuleHttpMethod, ModuleHttpRoute, ModuleSource,
};
#[test]
fn metadata_response_includes_manifest_lints() {
let modules = metadata_response_modules(vec![AdminModuleMetadata {
module_name: "remote-crm".to_owned(),
source: ModuleSource::Remote,
load_status: ModuleLoadStatus::Loaded,
http_routes: vec![ModuleHttpRoute {
method: ModuleHttpMethod::Get,
path: "/contacts/{id}".to_owned(),
capability: None,
display_name: Some("Fetch Contact".to_owned()),
story_title: Some("Fetch Contact".to_owned()),
operation: None,
}],
runtime: None,
events: None,
lifecycle: Some(LifecycleSurface {
startup_checks: Vec::new(),
activation_jobs: vec![LifecycleActivationJobDeclaration {
name: "warm contact cache".to_owned(),
function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
run_policy: LifecycleActivationRunPolicy::EveryStartup,
input: serde_json::json!({}),
required: true,
}],
}),
console: Vec::new(),
console_slots: Vec::new(),
console_contributions: Vec::new(),
story_display: Vec::new(),
capabilities: Vec::new(),
dependencies: vec!["auth".to_owned()],
admin: None,
source_diagnostics: None,
}]);
assert_eq!(modules[0].dependencies, vec!["auth"]);
assert!(modules[0].lifecycle.is_some());
assert!(modules[0].manifest_lints.iter().any(|lint| {
lint.subject == "lifecycle.activation_job.warm contact cache"
&& lint.message
== "Lifecycle activation job references an unknown runtime function."
}));
assert!(modules[0].manifest_lints.iter().any(|lint| {
lint.message == "Missing capability declaration for host proxy authorization."
}));
}
#[test]
fn metadata_response_includes_module_governance() {
let modules = metadata_response_modules(vec![AdminModuleMetadata {
module_name: "remote-crm".to_owned(),
source: ModuleSource::Remote,
load_status: ModuleLoadStatus::Loaded,
http_routes: vec![ModuleHttpRoute {
method: ModuleHttpMethod::Get,
path: "/contacts/{id}".to_owned(),
capability: Some("remote_crm.contacts.read".to_owned()),
display_name: Some("Fetch Contact".to_owned()),
story_title: Some("Fetch Contact".to_owned()),
operation: None,
}],
runtime: None,
events: None,
lifecycle: None,
console: vec![ConsoleSurface {
name: "contacts".to_owned(),
label: "Contacts".to_owned(),
area: ConsoleArea::Data,
route: "/data/contacts".to_owned(),
package: ConsolePackage {
name: "@lenso/contacts-console".to_owned(),
export: "contactsConsoleModule".to_owned(),
},
icon: None,
required_capabilities: vec!["remote_crm.contacts.read".to_owned()],
navigation: None,
}],
console_slots: Vec::new(),
console_contributions: vec![ConsoleContribution {
target: "auth.users.detail.actions".to_owned(),
target_version: 1,
label: "Reset password".to_owned(),
action: ConsoleContributionAction::AdminAction {
module: "auth-password".to_owned(),
name: "reset_password".to_owned(),
input_bindings: vec![ConsoleActionInputBinding {
input: "user_id".to_owned(),
value: ConsoleActionInputValue::SlotContext {
path: "selected_user.id".to_owned(),
},
}],
},
icon: Some("key-round".to_owned()),
required_capabilities: vec!["auth_password.credentials.write".to_owned()],
}],
story_display: Vec::new(),
capabilities: vec!["remote_crm.contacts.write".to_owned()],
dependencies: Vec::new(),
admin: Some(AdminSurface::Schema(AdminSchema {
entities: vec![EntitySchema {
name: "contacts".to_owned(),
label: "Contacts".to_owned(),
fields: vec![],
read_capability: "remote_crm.contacts.read".to_owned(),
}],
})),
source_diagnostics: None,
}]);
let governance = &modules[0].governance;
assert_eq!(
governance.activation_state,
AdminModuleActivationState::NeedsAttention
);
assert_eq!(governance.capability_summary.declared_count, 1);
assert_eq!(governance.capability_summary.referenced_count, 2);
assert_eq!(governance.capability_summary.missing_count, 4);
assert_eq!(governance.capability_summary.unused_count, 1);
assert_eq!(
governance.capability_issues[0].capability,
"remote_crm.contacts.read"
);
assert_eq!(
governance.capability_issues[0].subject,
"capability.reference.http_route.GET /contacts/{id}"
);
assert!(
governance
.capability_issues
.iter()
.any(|issue| { issue.subject == "capability.reference.console.surface.contacts" })
);
assert_eq!(modules[0].console.len(), 1);
assert_eq!(modules[0].console_contributions.len(), 1);
assert!(modules[0].manifest_lints.iter().any(|lint| {
lint.subject == "capability.reference.console.contribution.auth.users.detail.actions"
&& lint.message == "Capability reference is not declared by the module."
}));
assert!(governance.capability_issues.iter().any(|issue| {
issue.capability == "auth_password.credentials.write"
&& issue.subject
== "capability.reference.console.contribution.auth.users.detail.actions"
}));
}
#[test]
fn service_system_response_is_empty_without_manifest() {
let path =
std::env::temp_dir().join(format!("missing-lenso-system-{}.json", current_unix_ms()));
let response = service_system_response(&path);
assert!(matches!(response.status, AdminServiceSystemStatus::Empty));
assert!(response.services.is_empty());
}
#[test]
fn service_system_response_maps_graph() {
let path = std::env::temp_dir().join(format!("lenso-system-{}.json", current_unix_ms()));
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.system.v1",
"name": "support-platform",
"environments": ["local"],
"services": [
{ "name": "support", "target": "local", "modules": ["support-ticket"] },
{ "name": "billing", "target": "kubernetes", "modules": ["invoice"] }
],
"modules": [
{
"name": "support-ticket",
"installTo": "service:support",
"dependencies": ["billing.invoice.read"]
},
{
"name": "invoice",
"installTo": "service:billing",
"capabilities": ["billing.invoice.read"]
}
]
})
.to_string(),
)
.unwrap();
let response = service_system_response(&path);
assert!(matches!(response.status, AdminServiceSystemStatus::Ready));
assert_eq!(response.name.as_deref(), Some("support-platform"));
assert_eq!(response.dependencies[0].to.as_deref(), Some("billing"));
let _ = fs::remove_file(path);
}
#[test]
fn service_system_drift_reports_missing_host_state() {
let root = std::env::temp_dir().join(format!("lenso-system-drift-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let path = root.join("lenso.system.json");
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.system.v1",
"name": "support-platform",
"environments": ["staging"],
"services": [
{ "name": "support", "target": "operator", "modules": ["support-ticket"] }
],
"modules": [
{ "name": "support-ticket", "installTo": "service:support" }
]
})
.to_string(),
)
.unwrap();
let response = service_system_drift_response_from_paths(
&path,
&root.join("module-installs.json"),
&root.join("module-services.json"),
&root.join("service-environments.json"),
&root.join("service-deployments.json"),
&root.join("service-releases.json"),
);
assert_eq!(response.status, AdminServiceSystemDriftStatus::Drifted);
assert!(
response
.drifts
.iter()
.any(|drift| { drift.code == "service_not_configured" && drift.name == "support" })
);
assert!(response.drifts.iter().any(|drift| {
drift.code == "module_not_installed" && drift.name == "support-ticket"
}));
let _ = fs::remove_dir_all(root);
}
#[test]
fn service_system_release_train_reads_history() {
let root = std::env::temp_dir().join(format!("lenso-system-release-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let path = root.join("system-releases.json");
fs::write(
&path,
serde_json::json!({
"releases": [{
"appliedAtUnixMs": 1772300000000u64,
"environment": "staging",
"id": "sysrel_staging_1",
"kind": "release",
"modules": ["support-ticket"],
"policy": { "risk": "safe" },
"rollbackAvailable": true,
"services": [{ "name": "support", "target": "operator" }],
"status": "ready",
"systemName": "support-platform"
}],
"version": 1
})
.to_string(),
)
.unwrap();
let response = service_system_release_train_response(&path);
assert_eq!(response.status, AdminServiceSystemReleaseTrainStatus::Ready);
assert_eq!(response.releases[0].id, "sysrel_staging_1");
assert_eq!(response.releases[0].services, 1);
let _ = fs::remove_dir_all(root);
}
#[test]
fn service_system_runbooks_reads_active_runbook() {
let root = std::env::temp_dir().join(format!("lenso-system-runbook-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let path = root.join("system-runbooks.json");
fs::write(
&path,
serde_json::json!({
"runbooks": [{
"active": true,
"environment": "staging",
"id": "sysrun_staging_1",
"releaseId": "sysrel_staging_1",
"status": "ready",
"steps": [
{ "id": "check-release", "kind": "evidence", "status": "pending", "title": "Check system release" }
],
"systemName": "support-platform"
}],
"version": 1
})
.to_string(),
)
.unwrap();
let response = service_system_runbooks_response(&path);
assert_eq!(response.status, AdminServiceSystemRunbooksStatus::Ready);
assert_eq!(response.runbooks[0].id, "sysrun_staging_1");
assert_eq!(response.runbooks[0].steps, 1);
assert_eq!(
response.runbooks[0].current_step.as_deref(),
Some("Check system release")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn launchpad_response_reads_state() {
let root = std::env::temp_dir().join(format!("lenso-launchpad-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".lenso")).unwrap();
let path = root.join(".lenso/launchpad.json");
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.launchpad.v1",
"projectName": "support-desk",
"blueprint": "support-desk",
"status": "configured",
"summary": "Support desk app.",
"services": [{
"name": "support-api",
"role": "ticket intake",
"language": "ts",
"cwd": "services/support-api",
"command": "pnpm start",
"readyUrl": "http://127.0.0.1:4110/lenso/service/v1/status",
"modules": ["support-api"]
}],
"modules": [{
"name": "support-api",
"ownerService": "support-api",
"capability": "support.tickets"
}],
"commands": {
"devUp": "lenso dev up",
"devStatus": "lenso dev status",
"agentContext": "lenso agent context",
"console": "http://127.0.0.1:3000/launchpad"
},
"checklist": [{
"id": "dev-up",
"label": "Run services and host locally",
"status": "next",
"nextCommand": "lenso dev up"
}],
"addons": [{
"name": "support-sla",
"label": "Support SLA",
"status": "configured",
"services": ["support-sla"],
"modules": ["support-sla"]
}],
"supportedAddons": ["support-sla", "notifications"]
})
.to_string(),
)
.unwrap();
let response = launchpad_response(&path);
assert_eq!(response.status, AdminLaunchpadStatus::Ready);
assert_eq!(response.project_name.as_deref(), Some("support-desk"));
assert_eq!(response.blueprint.as_deref(), Some("support-desk"));
assert_eq!(response.services.len(), 1);
assert_eq!(response.modules.len(), 1);
assert_eq!(response.addons.len(), 1);
assert_eq!(
response.supported_addons,
vec!["support-sla", "notifications"]
);
assert_eq!(response.next_command.as_deref(), Some("lenso dev up"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn launchpad_doctor_response_reads_state() {
let root =
std::env::temp_dir().join(format!("lenso-launchpad-doctor-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".lenso")).unwrap();
let path = root.join(".lenso/dev-doctor.json");
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.dev-doctor.v1",
"status": "ready",
"checkedAtUnixMs": 1782903144460_u64,
"live": false,
"checks": [{
"id": "env",
"label": ".env file",
"status": "passed",
"message": ".env exists",
"command": null
}]
})
.to_string(),
)
.unwrap();
let response = launchpad_doctor_response(&path);
assert_eq!(response.status, AdminLaunchpadDoctorStatus::Ready);
assert_eq!(response.checked_at_unix_ms, Some(1782903144460));
assert!(!response.live);
assert_eq!(response.checks.len(), 1);
assert_eq!(response.next_command, None);
let _ = fs::remove_dir_all(root);
}
#[test]
fn launchpad_proof_response_reads_state() {
let root =
std::env::temp_dir().join(format!("lenso-launchpad-proof-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".lenso")).unwrap();
let path = root.join(".lenso/app-proof.json");
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.app-proof.v1",
"status": "ready",
"checkedAtUnixMs": 1782903144460_u64,
"projectName": "support-desk",
"blueprint": "support-desk",
"addons": ["support-sla"],
"checks": [{
"id": "launchpad",
"label": "Launchpad state",
"status": "passed",
"message": ".lenso/launchpad.json exists",
"command": null
}],
"drifts": [],
"nextCommand": null
})
.to_string(),
)
.unwrap();
let response = launchpad_proof_response(&path);
assert_eq!(response.status, AdminLaunchpadProofStatus::Ready);
assert_eq!(response.checked_at_unix_ms, Some(1782903144460));
assert_eq!(response.project_name.as_deref(), Some("support-desk"));
assert_eq!(response.blueprint.as_deref(), Some("support-desk"));
assert_eq!(response.addons, vec!["support-sla"]);
assert_eq!(response.checks.len(), 1);
assert!(response.drifts.is_empty());
assert_eq!(response.next_command, None);
let _ = fs::remove_dir_all(root);
}
#[test]
fn launchpad_change_plan_response_reads_changes() {
let root = std::env::temp_dir().join(format!("lenso-change-plan-{}", current_unix_ms()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".lenso")).unwrap();
let path = root.join(".lenso/app-change-plan.json");
fs::write(
&path,
serde_json::json!({
"protocol": "lenso.app-change-plan.v1",
"status": "changes",
"generatedAtUnixMs": 1782903144460_u64,
"projectName": "support-desk",
"blueprint": "support-desk",
"addons": ["support-sla"],
"proofStatus": "drifted",
"changes": [{
"id": "workspace-service-support-api",
"kind": "workspace-service",
"name": "support-api",
"action": "restore-workspace-service",
"safe": true,
"message": "support-api is missing from lenso.workspace.json",
"command": "lenso app apply .lenso/app-change-plan.json"
}],
"blocked": [],
"nextCommand": "lenso app apply .lenso/app-change-plan.json",
"composition": {
"protocol": "lenso.app-composition.v1",
"intent": "support desk with SLA",
"requestedAddons": ["support-sla", "customer-profile"],
"appliedAddons": ["support-sla"],
"pendingAddons": ["customer-profile"],
"requestedPacks": ["support-sla"],
"appliedPacks": [],
"pendingPacks": ["support-sla"],
"capabilityPacks": [{
"name": "support-sla",
"path": "../capabilities/support-sla",
"status": "pending",
"modules": ["support-sla"],
"services": ["support-sla-provider/api"],
"nextCommand": "lenso capability check ../capabilities/support-sla"
}],
"packFit": [{
"name": "support-sla",
"path": "../capabilities/support-sla",
"status": "ready",
"issues": [],
"command": "lenso app compose --pack support-sla"
}],
"serviceActions": [{
"id": "service:check:customer-profile",
"kind": "service_check",
"label": "Check customer-profile service readiness",
"command": "lenso service workspace check customer-profile",
"status": "recommended"
}],
"agentActions": []
}
})
.to_string(),
)
.unwrap();
let response = launchpad_change_plan_response(&path);
assert_eq!(response.status, AdminLaunchpadChangePlanStatus::Changes);
assert_eq!(response.generated_at_unix_ms, Some(1782903144460));
assert_eq!(response.project_name.as_deref(), Some("support-desk"));
assert_eq!(response.proof_status.as_deref(), Some("drifted"));
assert_eq!(response.changes.len(), 1);
assert!(response.changes[0].safe);
assert!(response.blocked.is_empty());
let composition = response.composition.expect("composition");
assert_eq!(composition.pending_addons, vec!["customer-profile"]);
assert_eq!(composition.pending_packs, vec!["support-sla"]);
assert_eq!(composition.capability_packs[0].name, "support-sla");
assert_eq!(composition.pack_fit[0].status, "ready");
assert_eq!(
composition.pack_fit[0].command.as_deref(),
Some("lenso app compose --pack support-sla")
);
assert_eq!(
composition.capability_packs[0].next_command.as_deref(),
Some("lenso capability check ../capabilities/support-sla")
);
assert_eq!(
composition.service_actions[0].command.as_deref(),
Some("lenso service workspace check customer-profile")
);
assert_eq!(
response.next_command.as_deref(),
Some("lenso app apply .lenso/app-change-plan.json")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn launchpad_change_plan_response_is_empty_without_file() {
let path = std::env::temp_dir().join(format!(
"missing-lenso-change-plan-{}.json",
current_unix_ms()
));
let response = launchpad_change_plan_response(&path);
assert_eq!(response.status, AdminLaunchpadChangePlanStatus::Empty);
assert!(response.changes.is_empty());
assert_eq!(
response.next_command.as_deref(),
Some("lenso app plan --write-plan")
);
}
}
#[utoipa::path(
get,
path = "/admin/data/{module}/{entity}",
operation_id = "admin_data_list_records",
tag = "admin-data",
params(
("module" = String, Path, description = "Module name, e.g. identity"),
("entity" = String, Path, description = "Entity name, e.g. users"),
("limit" = Option<i64>, Query, description = "Max records (default 50, max 200)"),
("cursor" = Option<String>, Query, description = "Opaque pagination cursor"),
("authorization" = String, Header, description = "Development service bearer token"),
),
responses(
(status = 200, description = "A page of records", body = AdminDataListResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown module or entity", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn list_records(
_admin: AdminActor,
HttpRequestContext(request_ctx): HttpRequestContext,
Path((module, entity)): Path<(String, String)>,
Query(query): Query<DataListQuery>,
) -> Result<Json<AdminDataListResponse>, ApiErrorResponse> {
let admin_module = find_loaded_module(&module, &request_ctx)?;
ensure_entity(&admin_module, &entity, &request_ctx)?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let page = admin_module
.data_source
.as_ref()
.expect("loaded admin module has data source")
.list(&entity, &AdminListQuery::new(limit, query.cursor))
.await
.map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
Ok(Json(AdminDataListResponse {
data: page.records,
page: AdminDataPageInfo {
limit,
next_cursor: page.next_cursor,
},
}))
}
#[utoipa::path(
get,
path = "/admin/data/{module}/{entity}/{id}",
operation_id = "admin_data_get_record",
tag = "admin-data",
params(
("module" = String, Path, description = "Module name"),
("entity" = String, Path, description = "Entity name"),
("id" = String, Path, description = "Record id"),
("authorization" = String, Header, description = "Development service bearer token"),
),
responses(
(status = 200, description = "One record", body = AdminDataDetailResponse, content_type = "application/json"),
(status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
(status = 404, description = "Unknown module/entity or record not found", body = ErrorResponse, content_type = "application/problem+json"),
)
)]
pub(crate) async fn get_record(
_admin: AdminActor,
HttpRequestContext(request_ctx): HttpRequestContext,
Path((module, entity, id)): Path<(String, String, String)>,
) -> Result<Json<AdminDataDetailResponse>, ApiErrorResponse> {
let admin_module = find_loaded_module(&module, &request_ctx)?;
ensure_entity(&admin_module, &entity, &request_ctx)?;
match admin_module
.data_source
.as_ref()
.expect("loaded admin module has data source")
.get(&entity, &id)
.await
.map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?
{
Some(data) => Ok(Json(AdminDataDetailResponse { data })),
None => Err(ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, "record not found"),
&request_ctx,
)),
}
}
fn ensure_entity(
module: &AdminModule,
entity: &str,
ctx: &RequestContext,
) -> Result<(), ApiErrorResponse> {
if module.schema.entities.iter().any(|e| e.name == entity) {
Ok(())
} else {
Err(ApiErrorResponse::with_context(
AppError::new(ErrorCode::NotFound, format!("unknown entity: {entity}")),
ctx,
))
}
}