1mod system_plane;
6
7pub use system_plane::*;
8
9use axum::{
10 Json,
11 extract::{Path as AxumPath, Query, State},
12};
13use lenso_module_management::{
14 ApproveModuleOperation, MANAGEMENT_HOLDER, ManagementActor, MigrationExecutionMode,
15 ModuleEffectAdapter, ModuleEffectAdapterError, ModuleEffectExecution, ModuleEffectOutcome,
16 ModulePlanEffect, ModuleRootChange, ServiceDeploymentAction, ServiceInstallationPlan,
17 StartReviewedModulePlan, WorkspaceModuleManagement, WorkspaceModuleOperator,
18 WorkspaceModuleOperatorError, WorkspaceServiceInstallationManager,
19 application_module_lock_digest,
20};
21use platform_core::{AppContext, AppError, DbPool, ErrorCode, Shutdown, apply_module_migration};
22use platform_http::{
23 AdminActor, ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext,
24 OpenApiRouter, routes,
25};
26use serde::Deserialize;
27use serde_json::{Value, json};
28use sha2::{Digest as _, Sha256};
29use std::{
30 collections::BTreeSet,
31 fs,
32 path::{Path, PathBuf},
33 process::Command,
34};
35
36pub fn router() -> ApiOpenApiRouter {
37 OpenApiRouter::new()
38 .routes(routes!(management_snapshot))
39 .routes(routes!(preview_change_plan))
40 .routes(routes!(start_operation))
41 .routes(routes!(get_operation))
42 .routes(routes!(get_operation_journal))
43 .routes(routes!(apply_operation))
44 .routes(routes!(approve_operation))
45 .routes(routes!(cancel_operation))
46 .routes(routes!(retry_operation))
47 .routes(routes!(resume_operation))
48 .routes(routes!(service_installation_snapshot))
49 .routes(routes!(preview_service_installation))
50 .routes(routes!(apply_service_installation_plan))
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct StartOperationBody {
56 idempotency_key: String,
57 plan: lenso_module_management::ModuleChangePlan,
58}
59
60#[derive(Debug, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct RevisionBody {
63 expected_revision: u64,
64}
65
66#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct ApprovalBody {
69 expected_revision: u64,
70 boundary_id: String,
71 reason: String,
72 nonce: String,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(deny_unknown_fields)]
77struct ServiceInstallationScope {
78 system_id: String,
79}
80
81#[derive(Debug, Deserialize)]
82#[serde(deny_unknown_fields)]
83struct PreviewServiceInstallationBody {
84 system_id: String,
85 environment_id: String,
86 change: lenso_module_management::ServiceInstallationChange,
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct ApplyServiceInstallationBody {
92 operation_id: String,
93 plan: ServiceInstallationPlan,
94}
95
96#[utoipa::path(
97 get,
98 path = "/admin/modules/management",
99 operation_id = "admin_modules_management_snapshot",
100 tag = "module-management",
101 params(("authorization" = String, Header, description = "Service or system bearer token")),
102 responses(
103 (status = 200, description = "Target-owned Module composition and planning readiness", body = Value, content_type = "application/json"),
104 (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
105 (status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
106 (status = 500, description = "Module management state cannot be read", body = ErrorResponse, content_type = "application/problem+json"),
107 )
108)]
109#[allow(clippy::result_large_err)]
110async fn management_snapshot(
111 _admin: AdminActor,
112 HttpRequestContext(request_context): HttpRequestContext,
113) -> Result<Json<Value>, ApiErrorResponse> {
114 let root = std::env::current_dir().map_err(|error| {
115 api_error(
116 ErrorCode::Internal,
117 format!("Module management root is unavailable: {error}"),
118 &request_context,
119 )
120 })?;
121 let snapshot = WorkspaceModuleManagement::new(root)
122 .snapshot()
123 .map_err(|error| {
124 api_error(
125 ErrorCode::Internal,
126 format!("Module management snapshot failed: {error}"),
127 &request_context,
128 )
129 })?;
130 serde_json::to_value(snapshot).map(Json).map_err(|error| {
131 api_error(
132 ErrorCode::Internal,
133 format!("Module management snapshot serialization failed: {error}"),
134 &request_context,
135 )
136 })
137}
138
139#[utoipa::path(
140 post,
141 path = "/admin/modules/plans/preview",
142 operation_id = "admin_modules_preview_change_plan",
143 tag = "module-management",
144 params(("authorization" = String, Header, description = "Service or system bearer token")),
145 request_body(content = Value, description = "One lenso ModuleRootChange value", content_type = "application/json"),
146 responses(
147 (status = 200, description = "Immutable complete Module Change Plan", body = Value, content_type = "application/json"),
148 (status = 400, description = "Change or planning input is invalid", body = ErrorResponse, content_type = "application/problem+json"),
149 (status = 401, description = "Authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
150 (status = 403, description = "Service or system authentication is required", body = ErrorResponse, content_type = "application/problem+json"),
151 (status = 409, description = "Planning context is not available", body = ErrorResponse, content_type = "application/problem+json"),
152 )
153)]
154#[allow(clippy::result_large_err)]
155async fn preview_change_plan(
156 _admin: AdminActor,
157 HttpRequestContext(request_context): HttpRequestContext,
158 Json(body): Json<Value>,
159) -> Result<Json<Value>, ApiErrorResponse> {
160 let change: ModuleRootChange = serde_json::from_value(body).map_err(|error| {
161 api_error(
162 ErrorCode::Validation,
163 format!("Module change is invalid: {error}"),
164 &request_context,
165 )
166 })?;
167 let root = std::env::current_dir().map_err(|error| {
168 api_error(
169 ErrorCode::Internal,
170 format!("Module management root is unavailable: {error}"),
171 &request_context,
172 )
173 })?;
174 let plan = WorkspaceModuleManagement::new(root)
175 .preview(change, chrono::Utc::now())
176 .map_err(|error| {
177 let code = if matches!(
178 error,
179 lenso_module_management::WorkspaceModuleManagementError::PlanningUnavailable
180 ) {
181 ErrorCode::Conflict
182 } else {
183 ErrorCode::Validation
184 };
185 api_error(
186 code,
187 format!("Module planning failed: {error}"),
188 &request_context,
189 )
190 })?;
191 serde_json::to_value(plan).map(Json).map_err(|error| {
192 api_error(
193 ErrorCode::Internal,
194 format!("Module plan serialization failed: {error}"),
195 &request_context,
196 )
197 })
198}
199
200#[utoipa::path(post, path = "/admin/modules/operations", operation_id = "admin_modules_start_operation", tag = "module-management", request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Durable Module operation", body = Value), (status = 400, description = "Reviewed plan is stale or invalid", body = ErrorResponse), (status = 401, description = "Authentication is required", body = ErrorResponse), (status = 403, description = "Management authority is required", body = ErrorResponse), (status = 409, description = "Operation conflicts with target state", body = ErrorResponse)))]
201#[allow(clippy::result_large_err)]
202async fn start_operation(
203 admin: AdminActor,
204 State(ctx): State<AppContext>,
205 HttpRequestContext(request_context): HttpRequestContext,
206 Json(body): Json<Value>,
207) -> Result<Json<Value>, ApiErrorResponse> {
208 let body: StartOperationBody = decode(body, &request_context)?;
209 let actor = management_actor(admin);
210 let root = management_root(&request_context)?;
211 let operator = WorkspaceModuleOperator::new(root, HostModuleEffectAdapter::new(&ctx));
212 let operation = operator
213 .start(
214 &StartReviewedModulePlan {
215 idempotency_key: body.idempotency_key,
216 plan: body.plan,
217 },
218 &actor,
219 MANAGEMENT_HOLDER,
220 chrono::Utc::now(),
221 )
222 .map_err(|error| operator_error(error, &request_context))?;
223 value(operation, &request_context)
224}
225
226#[utoipa::path(get, path = "/admin/modules/operations/{operation_id}", operation_id = "admin_modules_get_operation", tag = "module-management", params(("operation_id" = String, Path)), responses((status = 200, description = "Current durable Module operation", body = Value), (status = 404, description = "Operation was not found", body = ErrorResponse)))]
227#[allow(clippy::result_large_err)]
228async fn get_operation(
229 admin: AdminActor,
230 State(ctx): State<AppContext>,
231 HttpRequestContext(request_context): HttpRequestContext,
232 AxumPath(operation_id): AxumPath<String>,
233) -> Result<Json<Value>, ApiErrorResponse> {
234 let _actor = management_actor(admin);
235 let operator = WorkspaceModuleOperator::new(
236 management_root(&request_context)?,
237 HostModuleEffectAdapter::new(&ctx),
238 );
239 value(
240 operator
241 .operation(&operation_id)
242 .map_err(|error| operator_error(error, &request_context))?,
243 &request_context,
244 )
245}
246
247#[utoipa::path(get, path = "/admin/modules/operations/{operation_id}/journal", operation_id = "admin_modules_get_operation_journal", tag = "module-management", params(("operation_id" = String, Path)), responses((status = 200, description = "Hash-chained Module operation journal", body = Value), (status = 404, description = "Operation was not found", body = ErrorResponse)))]
248#[allow(clippy::result_large_err)]
249async fn get_operation_journal(
250 admin: AdminActor,
251 State(ctx): State<AppContext>,
252 HttpRequestContext(request_context): HttpRequestContext,
253 AxumPath(operation_id): AxumPath<String>,
254) -> Result<Json<Value>, ApiErrorResponse> {
255 let _actor = management_actor(admin);
256 let operator = WorkspaceModuleOperator::new(
257 management_root(&request_context)?,
258 HostModuleEffectAdapter::new(&ctx),
259 );
260 value(
261 operator
262 .journal(&operation_id)
263 .map_err(|error| operator_error(error, &request_context))?,
264 &request_context,
265 )
266}
267
268#[utoipa::path(post, path = "/admin/modules/operations/{operation_id}/apply", operation_id = "admin_modules_apply_operation", tag = "module-management", params(("operation_id" = String, Path)), responses((status = 200, description = "Applied, blocked, or completed Module operation", body = Value), (status = 409, description = "Operation cannot currently be applied", body = ErrorResponse)))]
269#[allow(clippy::result_large_err)]
270async fn apply_operation(
271 admin: AdminActor,
272 State(ctx): State<AppContext>,
273 HttpRequestContext(request_context): HttpRequestContext,
274 AxumPath(operation_id): AxumPath<String>,
275) -> Result<Json<Value>, ApiErrorResponse> {
276 let actor = management_actor(admin);
277 run_effectful(ctx, request_context, move |operator| {
278 operator.apply(&operation_id, &actor, chrono::Utc::now())
279 })
280 .await
281}
282
283#[utoipa::path(post, path = "/admin/modules/operations/{operation_id}/approvals", operation_id = "admin_modules_approve_operation", tag = "module-management", params(("operation_id" = String, Path)), request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Operation with plan-bound approval", body = Value), (status = 400, description = "Approval is invalid", body = ErrorResponse), (status = 409, description = "Approval is stale", body = ErrorResponse)))]
284#[allow(clippy::result_large_err)]
285async fn approve_operation(
286 admin: AdminActor,
287 State(ctx): State<AppContext>,
288 HttpRequestContext(request_context): HttpRequestContext,
289 AxumPath(operation_id): AxumPath<String>,
290 Json(body): Json<Value>,
291) -> Result<Json<Value>, ApiErrorResponse> {
292 let body: ApprovalBody = decode(body, &request_context)?;
293 let actor = management_actor(admin);
294 let operator = WorkspaceModuleOperator::new(
295 management_root(&request_context)?,
296 HostModuleEffectAdapter::new(&ctx),
297 );
298 let operation = operator
299 .approve(
300 &operation_id,
301 ApproveModuleOperation {
302 expected_revision: body.expected_revision,
303 boundary_id: body.boundary_id,
304 reason: body.reason,
305 nonce: body.nonce,
306 },
307 &actor,
308 MANAGEMENT_HOLDER,
309 chrono::Utc::now(),
310 )
311 .map_err(|error| operator_error(error, &request_context))?;
312 value(operation, &request_context)
313}
314
315#[utoipa::path(post, path = "/admin/modules/operations/{operation_id}/cancel", operation_id = "admin_modules_cancel_operation", tag = "module-management", params(("operation_id" = String, Path)), request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Cancelled pre-mutation Module operation", body = Value), (status = 409, description = "Cancellation is unsafe", body = ErrorResponse)))]
316#[allow(clippy::result_large_err)]
317async fn cancel_operation(
318 admin: AdminActor,
319 State(ctx): State<AppContext>,
320 HttpRequestContext(request_context): HttpRequestContext,
321 AxumPath(operation_id): AxumPath<String>,
322 Json(body): Json<Value>,
323) -> Result<Json<Value>, ApiErrorResponse> {
324 let body: RevisionBody = decode(body, &request_context)?;
325 let actor = management_actor(admin);
326 let operator = WorkspaceModuleOperator::new(
327 management_root(&request_context)?,
328 HostModuleEffectAdapter::new(&ctx),
329 );
330 value(
331 operator
332 .cancel(
333 &operation_id,
334 body.expected_revision,
335 &actor,
336 chrono::Utc::now(),
337 )
338 .map_err(|error| operator_error(error, &request_context))?,
339 &request_context,
340 )
341}
342
343#[utoipa::path(post, path = "/admin/modules/operations/{operation_id}/retry", operation_id = "admin_modules_retry_operation", tag = "module-management", params(("operation_id" = String, Path)), request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Retried Module operation", body = Value), (status = 409, description = "Operation cannot be retried", body = ErrorResponse)))]
344#[allow(clippy::result_large_err)]
345async fn retry_operation(
346 admin: AdminActor,
347 State(ctx): State<AppContext>,
348 HttpRequestContext(request_context): HttpRequestContext,
349 AxumPath(operation_id): AxumPath<String>,
350 Json(body): Json<Value>,
351) -> Result<Json<Value>, ApiErrorResponse> {
352 let body: RevisionBody = decode(body, &request_context)?;
353 let actor = management_actor(admin);
354 run_effectful(ctx, request_context, move |operator| {
355 operator.retry(
356 &operation_id,
357 body.expected_revision,
358 &actor,
359 chrono::Utc::now(),
360 )
361 })
362 .await
363}
364
365#[utoipa::path(post, path = "/admin/modules/operations/{operation_id}/resume", operation_id = "admin_modules_resume_operation", tag = "module-management", params(("operation_id" = String, Path)), request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Crash-resumed Module operation", body = Value), (status = 409, description = "Safe continuation cannot be proven", body = ErrorResponse)))]
366#[allow(clippy::result_large_err)]
367async fn resume_operation(
368 admin: AdminActor,
369 State(ctx): State<AppContext>,
370 HttpRequestContext(request_context): HttpRequestContext,
371 AxumPath(operation_id): AxumPath<String>,
372 Json(body): Json<Value>,
373) -> Result<Json<Value>, ApiErrorResponse> {
374 let body: RevisionBody = decode(body, &request_context)?;
375 let actor = management_actor(admin);
376 run_effectful(ctx, request_context, move |operator| {
377 operator.resume(
378 &operation_id,
379 body.expected_revision,
380 &actor,
381 chrono::Utc::now(),
382 )
383 })
384 .await
385}
386
387#[utoipa::path(get, path = "/admin/services/installations/{environment_id}", operation_id = "admin_services_installation_snapshot", tag = "service-management", params(("environment_id" = String, Path), ("system_id" = String, Query), ("authorization" = String, Header, description = "Service or system bearer token")), responses((status = 200, description = "Target-owned desired Service Installation Set", body = Value), (status = 401, description = "Authentication is required", body = ErrorResponse), (status = 403, description = "Service or system authentication is required", body = ErrorResponse), (status = 500, description = "Installation state cannot be read", body = ErrorResponse)))]
388#[allow(clippy::result_large_err)]
389async fn service_installation_snapshot(
390 _admin: AdminActor,
391 HttpRequestContext(request_context): HttpRequestContext,
392 AxumPath(environment_id): AxumPath<String>,
393 Query(scope): Query<ServiceInstallationScope>,
394) -> Result<Json<Value>, ApiErrorResponse> {
395 let manager = WorkspaceServiceInstallationManager::new(
396 management_root(&request_context)?,
397 scope.system_id,
398 environment_id,
399 );
400 value(
401 manager
402 .snapshot()
403 .map_err(|error| service_installation_error(error, &request_context))?,
404 &request_context,
405 )
406}
407
408#[utoipa::path(post, path = "/admin/services/installations/plans/preview", operation_id = "admin_services_preview_installation", tag = "service-management", request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Immutable Service Installation Plan", body = Value), (status = 400, description = "Installation change is invalid", body = ErrorResponse), (status = 401, description = "Authentication is required", body = ErrorResponse), (status = 403, description = "Service or system authentication is required", body = ErrorResponse)))]
409#[allow(clippy::result_large_err)]
410async fn preview_service_installation(
411 _admin: AdminActor,
412 HttpRequestContext(request_context): HttpRequestContext,
413 Json(body): Json<Value>,
414) -> Result<Json<Value>, ApiErrorResponse> {
415 let body: PreviewServiceInstallationBody = decode(body, &request_context)?;
416 let manager = WorkspaceServiceInstallationManager::new(
417 management_root(&request_context)?,
418 body.system_id,
419 body.environment_id,
420 );
421 value(
422 manager
423 .preview(body.change, chrono::Utc::now())
424 .map_err(|error| service_installation_error(error, &request_context))?,
425 &request_context,
426 )
427}
428
429#[utoipa::path(post, path = "/admin/services/installations/plans/{plan_id}/apply", operation_id = "admin_services_apply_installation", tag = "service-management", params(("plan_id" = String, Path)), request_body(content = Value, content_type = "application/json"), responses((status = 200, description = "Durable Service Installation Receipt", body = Value), (status = 400, description = "Installation plan is invalid", body = ErrorResponse), (status = 401, description = "Authentication is required", body = ErrorResponse), (status = 403, description = "service.manage authority is required", body = ErrorResponse), (status = 409, description = "Installation Set changed after preview", body = ErrorResponse)))]
430#[allow(clippy::result_large_err)]
431async fn apply_service_installation_plan(
432 admin: AdminActor,
433 HttpRequestContext(request_context): HttpRequestContext,
434 AxumPath(plan_id): AxumPath<String>,
435 Json(body): Json<Value>,
436) -> Result<Json<Value>, ApiErrorResponse> {
437 let body: ApplyServiceInstallationBody = decode(body, &request_context)?;
438 if body.plan.plan_id != plan_id {
439 return Err(api_error(
440 ErrorCode::Validation,
441 "Service Installation plan identity differs from request path".to_owned(),
442 &request_context,
443 ));
444 }
445 let actor = management_actor(admin);
446 let manager = WorkspaceServiceInstallationManager::new(
447 management_root(&request_context)?,
448 &body.plan.system_id,
449 &body.plan.environment_id,
450 );
451 value(
452 manager
453 .apply(
454 &body.operation_id,
455 &body.plan,
456 &actor.actor_id,
457 &actor.verified_authorities,
458 chrono::Utc::now(),
459 )
460 .map_err(|error| service_installation_error(error, &request_context))?,
461 &request_context,
462 )
463}
464
465#[derive(Debug, Clone)]
466struct HostModuleEffectAdapter {
467 shutdown: Shutdown,
468 db: DbPool,
469 runtime: tokio::runtime::Handle,
470}
471
472impl HostModuleEffectAdapter {
473 fn new(ctx: &AppContext) -> Self {
474 Self {
475 shutdown: ctx.shutdown.clone(),
476 db: ctx.db.clone(),
477 runtime: tokio::runtime::Handle::current(),
478 }
479 }
480}
481
482impl ModuleEffectAdapter for HostModuleEffectAdapter {
483 fn execute(
484 &self,
485 workspace_root: &Path,
486 operation: &lenso_module_management::ModuleOperation,
487 effect: &ModulePlanEffect,
488 ) -> Result<ModuleEffectExecution, ModuleEffectAdapterError> {
489 let outcome = match effect {
490 ModulePlanEffect::Validate {
491 effect_id,
492 command,
493 expected_evidence,
494 } if command == "cargo check --locked" => {
495 let lock = fs::read(workspace_root.join("Cargo.lock"))
496 .map_err(|error| failed(effect_id, error))?;
497 if sha256(&lock) != *expected_evidence {
498 return Err(failed(
499 effect_id,
500 "Cargo.lock no longer matches the reviewed candidate",
501 ));
502 }
503 let output = Command::new("cargo")
504 .args(["check", "--locked"])
505 .current_dir(workspace_root)
506 .output()
507 .map_err(|error| failed(effect_id, error))?;
508 if !output.status.success() {
509 return Err(failed(effect_id, String::from_utf8_lossy(&output.stderr)));
510 }
511 ModuleEffectOutcome::Verified
512 }
513 ModulePlanEffect::Restart { target, .. } if target == "host" => {
514 self.shutdown.signal();
515 ModuleEffectOutcome::Applied
516 }
517 ModulePlanEffect::Activate {
518 effect_id,
519 target_lock_digest,
520 } => {
521 let bytes = fs::read(workspace_root.join("lenso.modules.lock.json"))
522 .map_err(|error| failed(effect_id, error))?;
523 let lock =
524 serde_json::from_slice(&bytes).map_err(|error| failed(effect_id, error))?;
525 if application_module_lock_digest(&lock)
526 .map_err(|error| failed(effect_id, error))?
527 != *target_lock_digest
528 {
529 return Err(failed(
530 effect_id,
531 "application lock does not match reviewed target",
532 ));
533 }
534 ModuleEffectOutcome::Activated
535 }
536 ModulePlanEffect::Migration {
537 effect_id,
538 module_id,
539 release_digest,
540 migration_id,
541 artifact_locator,
542 artifact_digest,
543 store_scope,
544 execution,
545 ..
546 } if store_scope == "host" && *execution == MigrationExecutionMode::Transactional => {
547 let artifact = verified_workspace_artifact(
548 workspace_root,
549 artifact_locator,
550 artifact_digest,
551 effect_id,
552 )?;
553 let sql = std::str::from_utf8(&artifact.bytes)
554 .map_err(|error| failed(effect_id, error))?;
555 let name = format!("{module_id}/{release_digest}/{migration_id}");
556 self.runtime
557 .block_on(apply_module_migration(
558 &self.db,
559 &name,
560 artifact_digest,
561 sql,
562 ))
563 .map_err(|error| failed(effect_id, error))?;
564 return Ok(ModuleEffectExecution {
565 outcome: ModuleEffectOutcome::Applied,
566 evidence_references: vec![
567 artifact.reference,
568 write_effect_receipt(
569 workspace_root,
570 operation,
571 effect,
572 "module_migration_applied",
573 )?,
574 ],
575 });
576 }
577 ModulePlanEffect::ServiceInstallation {
578 installation_plan,
579 action,
580 ..
581 } => {
582 if let Some(receipt) = existing_effect_receipt(workspace_root, effect)? {
583 return Ok(ModuleEffectExecution {
584 outcome: ModuleEffectOutcome::Applied,
585 evidence_references: vec![receipt],
586 });
587 }
588 let mut references = Vec::new();
589 if let Some(plan) = installation_plan {
590 references.push(apply_service_installation(
591 workspace_root,
592 operation,
593 effect,
594 plan,
595 )?);
596 }
597 let action =
598 action
599 .as_ref()
600 .ok_or_else(|| ModuleEffectAdapterError::Unsupported {
601 effect_id: effect.effect_id().to_owned(),
602 reason:
603 "desired Service installation was applied but no target-owned deployment action is available"
604 .to_owned(),
605 })?;
606 references.push(execute_service_action(workspace_root, effect, action)?);
607 references.push(write_effect_receipt(
608 workspace_root,
609 operation,
610 effect,
611 "service_installation_and_deployment_applied",
612 )?);
613 return Ok(ModuleEffectExecution {
614 outcome: ModuleEffectOutcome::Applied,
615 evidence_references: references,
616 });
617 }
618 ModulePlanEffect::ConsoleComposition { effect_id, .. } => {
619 if let Some(receipt) = existing_effect_receipt(workspace_root, effect)? {
620 return Ok(ModuleEffectExecution {
621 outcome: ModuleEffectOutcome::Applied,
622 evidence_references: vec![receipt],
623 });
624 }
625 let management_url =
626 std::env::var("LENSO_CONSOLE_MANAGEMENT_URL").map_err(|_| {
627 ModuleEffectAdapterError::Unsupported {
628 effect_id: effect_id.clone(),
629 reason: "LENSO_CONSOLE_MANAGEMENT_URL is not configured".to_owned(),
630 }
631 })?;
632 let management_token =
633 std::env::var("LENSO_CONSOLE_MANAGEMENT_TOKEN").map_err(|_| {
634 ModuleEffectAdapterError::Unsupported {
635 effect_id: effect_id.clone(),
636 reason: "LENSO_CONSOLE_MANAGEMENT_TOKEN is not configured".to_owned(),
637 }
638 })?;
639 let management_url = reqwest::Url::parse(&management_url)
640 .map_err(|error| failed(effect_id, error))?;
641 if management_url.scheme() != "https"
642 && !(management_url.scheme() == "http"
643 && management_url.host_str().is_some_and(|host| {
644 host == "localhost"
645 || host
646 .parse::<std::net::IpAddr>()
647 .is_ok_and(|ip| ip.is_loopback())
648 }))
649 {
650 return Err(failed(
651 effect_id,
652 "Console management URL must use HTTPS or loopback HTTP",
653 ));
654 }
655 let endpoint = format!(
656 "{}/api/console/v1/artifacts/reconcile",
657 management_url.as_str().trim_end_matches('/')
658 );
659 let client = reqwest::Client::builder()
660 .redirect(reqwest::redirect::Policy::none())
661 .build()
662 .map_err(|error| failed(effect_id, error))?;
663 let response = self
664 .runtime
665 .block_on(
666 client
667 .post(endpoint)
668 .bearer_auth(management_token)
669 .json(effect)
670 .send(),
671 )
672 .map_err(|error| failed(effect_id, error))?;
673 if !response.status().is_success() {
674 let status = response.status();
675 let body = self.runtime.block_on(response.text()).unwrap_or_default();
676 return Err(failed(
677 effect_id,
678 format!("Console composition request failed with {status}: {body}"),
679 ));
680 }
681 let receipt = write_effect_receipt(
682 workspace_root,
683 operation,
684 effect,
685 "console_composition_reconciled",
686 )?;
687 return Ok(ModuleEffectExecution {
688 outcome: ModuleEffectOutcome::Applied,
689 evidence_references: vec![receipt],
690 });
691 }
692 ModulePlanEffect::ServiceRemoval { action, .. }
693 | ModulePlanEffect::ServiceRestart { action, .. } => {
694 if let Some(receipt) = existing_effect_receipt(workspace_root, effect)? {
695 return Ok(ModuleEffectExecution {
696 outcome: ModuleEffectOutcome::Applied,
697 evidence_references: vec![receipt],
698 });
699 }
700 let action =
701 action
702 .as_ref()
703 .ok_or_else(|| ModuleEffectAdapterError::Unsupported {
704 effect_id: effect.effect_id().to_owned(),
705 reason: "reviewed plan has no target-owned Service deployment action"
706 .to_owned(),
707 })?;
708 let evidence = execute_service_action(workspace_root, effect, action)?;
709 let mut references = vec![evidence];
710 references.push(write_effect_receipt(
711 workspace_root,
712 operation,
713 effect,
714 "service_deployment_action_applied",
715 )?);
716 return Ok(ModuleEffectExecution {
717 outcome: ModuleEffectOutcome::Applied,
718 evidence_references: references,
719 });
720 }
721 _ => {
722 return Err(ModuleEffectAdapterError::Unsupported {
723 effect_id: effect.effect_id().to_owned(),
724 reason: "no deployment or migration adapter is configured for this target"
725 .to_owned(),
726 });
727 }
728 };
729 Ok(ModuleEffectExecution {
730 outcome,
731 evidence_references: Vec::new(),
732 })
733 }
734}
735
736#[allow(clippy::result_large_err)]
737async fn run_effectful(
738 ctx: AppContext,
739 request_context: platform_core::RequestContext,
740 run: impl FnOnce(
741 WorkspaceModuleOperator<HostModuleEffectAdapter>,
742 )
743 -> Result<lenso_module_management::ModuleOperation, WorkspaceModuleOperatorError>
744 + Send
745 + 'static,
746) -> Result<Json<Value>, ApiErrorResponse> {
747 let root = management_root(&request_context)?;
748 let adapter = HostModuleEffectAdapter::new(&ctx);
749 let result =
750 tokio::task::spawn_blocking(move || run(WorkspaceModuleOperator::new(root, adapter)))
751 .await
752 .map_err(|error| {
753 api_error(
754 ErrorCode::Internal,
755 format!("Module operation task failed: {error}"),
756 &request_context,
757 )
758 })?;
759 value(
760 result.map_err(|error| operator_error(error, &request_context))?,
761 &request_context,
762 )
763}
764
765struct VerifiedArtifact {
766 bytes: Vec<u8>,
767 reference: lenso_contracts::ArtifactReference,
768}
769
770fn verified_workspace_artifact(
771 workspace_root: &Path,
772 locator: &str,
773 expected_digest: &str,
774 effect_id: &str,
775) -> Result<VerifiedArtifact, ModuleEffectAdapterError> {
776 let root = workspace_root
777 .canonicalize()
778 .map_err(|error| failed(effect_id, error))?;
779 let path = workspace_root
780 .join(locator)
781 .canonicalize()
782 .map_err(|error| failed(effect_id, error))?;
783 if !path.starts_with(&root) || !path.is_file() {
784 return Err(failed(effect_id, "artifact escapes the managed workspace"));
785 }
786 let bytes = fs::read(&path).map_err(|error| failed(effect_id, error))?;
787 if sha256(&bytes) != expected_digest {
788 return Err(failed(
789 effect_id,
790 "artifact digest differs from the reviewed plan",
791 ));
792 }
793 Ok(VerifiedArtifact {
794 bytes,
795 reference: lenso_contracts::ArtifactReference {
796 locator: locator.to_owned(),
797 digest: expected_digest.to_owned(),
798 },
799 })
800}
801
802fn execute_service_action(
803 workspace_root: &Path,
804 effect: &ModulePlanEffect,
805 action: &ServiceDeploymentAction,
806) -> Result<lenso_contracts::ArtifactReference, ModuleEffectAdapterError> {
807 let effect_id = effect.effect_id();
808 match action {
809 ServiceDeploymentAction::Evidence { receipt } => Ok(verified_workspace_artifact(
810 workspace_root,
811 &receipt.locator,
812 &receipt.digest,
813 effect_id,
814 )?
815 .reference),
816 ServiceDeploymentAction::Command {
817 program,
818 args,
819 working_directory,
820 } => {
821 let executable = program.rsplit(['/', '\\']).next().unwrap_or_default();
822 if matches!(
823 executable.to_ascii_lowercase().as_str(),
824 "sh" | "bash"
825 | "dash"
826 | "zsh"
827 | "fish"
828 | "cmd"
829 | "cmd.exe"
830 | "powershell"
831 | "powershell.exe"
832 | "pwsh"
833 | "pwsh.exe"
834 ) {
835 return Err(failed(
836 effect_id,
837 "shell programs are not valid deployment adapters",
838 ));
839 }
840 let directory =
841 command_directory(workspace_root, working_directory.as_deref(), effect_id)?;
842 let output = Command::new(program)
843 .args(args)
844 .current_dir(directory)
845 .output()
846 .map_err(|error| failed(effect_id, error))?;
847 if !output.status.success() {
848 return Err(failed(
849 effect_id,
850 format!("Service deployment command exited with {}", output.status),
851 ));
852 }
853 let digest = lenso_contracts::digest_json(&json!({
854 "effect": effect,
855 "program": program,
856 "args": args,
857 "exitCode": output.status.code(),
858 }))
859 .map_err(|error| failed(effect_id, error))?;
860 Ok(lenso_contracts::ArtifactReference {
861 locator: format!("command:{program}"),
862 digest,
863 })
864 }
865 }
866}
867
868fn command_directory(
869 workspace_root: &Path,
870 relative: Option<&str>,
871 effect_id: &str,
872) -> Result<PathBuf, ModuleEffectAdapterError> {
873 let root = workspace_root
874 .canonicalize()
875 .map_err(|error| failed(effect_id, error))?;
876 let directory = relative.map_or_else(|| root.clone(), |path| workspace_root.join(path));
877 let directory = directory
878 .canonicalize()
879 .map_err(|error| failed(effect_id, error))?;
880 if !directory.starts_with(&root) || !directory.is_dir() {
881 return Err(failed(
882 effect_id,
883 "command working directory escapes the managed workspace",
884 ));
885 }
886 Ok(directory)
887}
888
889fn write_effect_receipt(
890 workspace_root: &Path,
891 operation: &lenso_module_management::ModuleOperation,
892 effect: &ModulePlanEffect,
893 outcome: &str,
894) -> Result<lenso_contracts::ArtifactReference, ModuleEffectAdapterError> {
895 let effect_id = effect.effect_id();
896 let effect_digest =
897 lenso_contracts::digest_json(effect).map_err(|error| failed(effect_id, error))?;
898 let relative = format!(
899 ".lenso/module-management/effect-evidence/{}.json",
900 effect_digest.trim_start_matches("sha256:")
901 );
902 let path = workspace_root.join(&relative);
903 if let Some(existing) = existing_effect_receipt(workspace_root, effect)? {
904 return Ok(existing);
905 }
906 let document = serde_json::to_vec_pretty(&json!({
907 "protocol": "lenso.module-effect-evidence.v1",
908 "operationId": operation.operation_id,
909 "attempt": operation.attempt,
910 "effectId": effect_id,
911 "effectDigest": effect_digest,
912 "outcome": outcome,
913 }))
914 .map_err(|error| failed(effect_id, error))?;
915 let parent = path
916 .parent()
917 .ok_or_else(|| failed(effect_id, "receipt path has no parent"))?;
918 fs::create_dir_all(parent).map_err(|error| failed(effect_id, error))?;
919 let temporary = path.with_extension(format!("json.tmp-{}", std::process::id()));
920 fs::write(&temporary, &document).map_err(|error| failed(effect_id, error))?;
921 fs::rename(&temporary, &path).map_err(|error| failed(effect_id, error))?;
922 Ok(lenso_contracts::ArtifactReference {
923 locator: relative,
924 digest: sha256(&document),
925 })
926}
927
928fn existing_effect_receipt(
929 workspace_root: &Path,
930 effect: &ModulePlanEffect,
931) -> Result<Option<lenso_contracts::ArtifactReference>, ModuleEffectAdapterError> {
932 let effect_id = effect.effect_id();
933 let effect_digest =
934 lenso_contracts::digest_json(effect).map_err(|error| failed(effect_id, error))?;
935 let relative = format!(
936 ".lenso/module-management/effect-evidence/{}.json",
937 effect_digest.trim_start_matches("sha256:")
938 );
939 let path = workspace_root.join(&relative);
940 let bytes = match fs::read(&path) {
941 Ok(bytes) => bytes,
942 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
943 Err(error) => return Err(failed(effect_id, error)),
944 };
945 let document: Value =
946 serde_json::from_slice(&bytes).map_err(|error| failed(effect_id, error))?;
947 if document.get("protocol").and_then(Value::as_str) != Some("lenso.module-effect-evidence.v1")
948 || document.get("effectDigest").and_then(Value::as_str) != Some(&effect_digest)
949 || document.get("outcome").and_then(Value::as_str).is_none()
950 {
951 return Err(failed(
952 effect_id,
953 "persisted effect evidence does not match the reviewed effect",
954 ));
955 }
956 Ok(Some(lenso_contracts::ArtifactReference {
957 locator: relative,
958 digest: sha256(&bytes),
959 }))
960}
961
962fn management_actor(admin: AdminActor) -> ManagementActor {
963 match admin {
964 AdminActor::Service { service_id, scopes } => ManagementActor {
965 actor_id: format!("service:{service_id}"),
966 verified_authorities: scopes.into_iter().collect(),
967 },
968 AdminActor::User { user_id, scopes } => ManagementActor {
969 actor_id: format!("user:{user_id}"),
970 verified_authorities: scopes.into_iter().collect(),
971 },
972 AdminActor::System => ManagementActor {
973 actor_id: "system".to_owned(),
974 verified_authorities: BTreeSet::from([
975 "module.manage".to_owned(),
976 "module.migrate.destructive".to_owned(),
977 "module.data.delete".to_owned(),
978 "module.trust.override".to_owned(),
979 "service.manage".to_owned(),
980 ]),
981 },
982 }
983}
984
985fn apply_service_installation(
986 workspace_root: &Path,
987 operation: &lenso_module_management::ModuleOperation,
988 effect: &ModulePlanEffect,
989 plan: &ServiceInstallationPlan,
990) -> Result<lenso_contracts::ArtifactReference, ModuleEffectAdapterError> {
991 let effect_id = effect.effect_id();
992 let effect_digest =
993 lenso_contracts::digest_json(effect).map_err(|error| failed(effect_id, error))?;
994 let service_operation_id = format!(
995 "{}-{}",
996 operation.operation_id,
997 &effect_digest.trim_start_matches("sha256:")[..16]
998 );
999 let authorities = operation
1000 .verified_authorities
1001 .iter()
1002 .cloned()
1003 .collect::<BTreeSet<_>>();
1004 WorkspaceServiceInstallationManager::new(workspace_root, &plan.system_id, &plan.environment_id)
1005 .apply(
1006 &service_operation_id,
1007 plan,
1008 &operation.actor_id,
1009 &authorities,
1010 chrono::Utc::now(),
1011 )
1012 .map_err(|error| failed(effect_id, error))?;
1013 let relative = format!(
1014 ".lenso/environments/{}/service-install-receipts/{service_operation_id}.json",
1015 plan.environment_id
1016 );
1017 let bytes =
1018 fs::read(workspace_root.join(&relative)).map_err(|error| failed(effect_id, error))?;
1019 Ok(lenso_contracts::ArtifactReference {
1020 locator: relative,
1021 digest: sha256(&bytes),
1022 })
1023}
1024
1025#[allow(clippy::result_large_err)]
1026fn management_root(
1027 context: &platform_core::RequestContext,
1028) -> Result<std::path::PathBuf, ApiErrorResponse> {
1029 std::env::current_dir().map_err(|error| {
1030 api_error(
1031 ErrorCode::Internal,
1032 format!("Module management root is unavailable: {error}"),
1033 context,
1034 )
1035 })
1036}
1037#[allow(clippy::result_large_err)]
1038fn decode<T: serde::de::DeserializeOwned>(
1039 body: Value,
1040 context: &platform_core::RequestContext,
1041) -> Result<T, ApiErrorResponse> {
1042 serde_json::from_value(body).map_err(|error| {
1043 api_error(
1044 ErrorCode::Validation,
1045 format!("Module operation request is invalid: {error}"),
1046 context,
1047 )
1048 })
1049}
1050#[allow(clippy::result_large_err)]
1051fn value<T: serde::Serialize>(
1052 body: T,
1053 context: &platform_core::RequestContext,
1054) -> Result<Json<Value>, ApiErrorResponse> {
1055 serde_json::to_value(body).map(Json).map_err(|error| {
1056 api_error(
1057 ErrorCode::Internal,
1058 format!("Module operation serialization failed: {error}"),
1059 context,
1060 )
1061 })
1062}
1063#[allow(clippy::needless_pass_by_value)]
1064fn operator_error(
1065 error: WorkspaceModuleOperatorError,
1066 context: &platform_core::RequestContext,
1067) -> ApiErrorResponse {
1068 let code = match &error {
1069 WorkspaceModuleOperatorError::Store(
1070 lenso_module_management::ModuleOperationStoreError::NotFound(_),
1071 ) => ErrorCode::NotFound,
1072 WorkspaceModuleOperatorError::Management(
1073 lenso_module_management::ModuleManagementError::MissingAuthority(_),
1074 ) => ErrorCode::Forbidden,
1075 WorkspaceModuleOperatorError::StalePlan
1076 | WorkspaceModuleOperatorError::CancellationUnsafe
1077 | WorkspaceModuleOperatorError::PolicyUnavailable
1078 | WorkspaceModuleOperatorError::Management(_)
1079 | WorkspaceModuleOperatorError::Store(_) => ErrorCode::Conflict,
1080 _ => ErrorCode::Internal,
1081 };
1082 api_error(code, format!("Module operation failed: {error}"), context)
1083}
1084
1085#[allow(clippy::needless_pass_by_value)]
1086fn service_installation_error(
1087 error: lenso_module_management::ServiceInstallationError,
1088 context: &platform_core::RequestContext,
1089) -> ApiErrorResponse {
1090 let code = match error {
1091 lenso_module_management::ServiceInstallationError::InvalidContract(_)
1092 | lenso_module_management::ServiceInstallationError::UnsafeOperationIdentity
1093 | lenso_module_management::ServiceInstallationError::Json(_) => ErrorCode::Validation,
1094 lenso_module_management::ServiceInstallationError::MissingAuthority(_) => {
1095 ErrorCode::Forbidden
1096 }
1097 lenso_module_management::ServiceInstallationError::StaleState => ErrorCode::Conflict,
1098 lenso_module_management::ServiceInstallationError::Io(_) => ErrorCode::Internal,
1099 };
1100 api_error(
1101 code,
1102 format!("Service Installation operation failed: {error}"),
1103 context,
1104 )
1105}
1106fn failed(effect_id: &str, reason: impl std::fmt::Display) -> ModuleEffectAdapterError {
1107 ModuleEffectAdapterError::Failed {
1108 effect_id: effect_id.to_owned(),
1109 reason: reason.to_string(),
1110 }
1111}
1112fn sha256(bytes: &[u8]) -> String {
1113 let digest = Sha256::digest(bytes);
1114 let mut value = String::from("sha256:");
1115 for byte in digest {
1116 use std::fmt::Write as _;
1117 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
1118 }
1119 value
1120}
1121
1122fn api_error(
1123 code: ErrorCode,
1124 message: String,
1125 context: &platform_core::RequestContext,
1126) -> ApiErrorResponse {
1127 ApiErrorResponse::with_context(AppError::new(code, message), context)
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133 use lenso_module_management::ServiceDeploymentAdapterKind;
1134 use std::sync::atomic::{AtomicU64, Ordering};
1135
1136 static NEXT_ROOT: AtomicU64 = AtomicU64::new(1);
1137
1138 fn root(name: &str) -> PathBuf {
1139 let root = std::env::temp_dir().join(format!(
1140 "lenso-module-effect-{name}-{}-{}",
1141 std::process::id(),
1142 NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
1143 ));
1144 fs::create_dir_all(&root).unwrap();
1145 root
1146 }
1147
1148 fn effect(action: ServiceDeploymentAction) -> ModulePlanEffect {
1149 ModulePlanEffect::ServiceInstallation {
1150 effect_id: "service-install:test".to_owned(),
1151 service_id: "acme/support".to_owned(),
1152 service_release_digest: format!("sha256:{}", "a".repeat(64)),
1153 installation_plan: None,
1154 adapter: Some(ServiceDeploymentAdapterKind::Local),
1155 action: Some(action),
1156 }
1157 }
1158
1159 #[test]
1160 fn evidence_action_rejects_digest_drift() {
1161 let root = root("evidence");
1162 fs::write(root.join("deployment.json"), b"observed").unwrap();
1163 let action = ServiceDeploymentAction::Evidence {
1164 receipt: lenso_contracts::ArtifactReference {
1165 locator: "deployment.json".to_owned(),
1166 digest: format!("sha256:{}", "0".repeat(64)),
1167 },
1168 };
1169 let error = execute_service_action(&root, &effect(action.clone()), &action).unwrap_err();
1170 assert!(error.to_string().contains("digest differs"));
1171 fs::remove_dir_all(root).unwrap();
1172 }
1173
1174 #[test]
1175 fn command_action_uses_argv_without_a_shell() {
1176 let root = root("command");
1177 let action = ServiceDeploymentAction::Command {
1178 program: "rustc".to_owned(),
1179 args: vec!["--version".to_owned()],
1180 working_directory: None,
1181 };
1182 let reference = execute_service_action(&root, &effect(action.clone()), &action).unwrap();
1183 assert_eq!(reference.locator, "command:rustc");
1184 assert!(reference.digest.starts_with("sha256:"));
1185 fs::remove_dir_all(root).unwrap();
1186 }
1187
1188 #[test]
1189 fn command_action_rejects_shell_programs() {
1190 let root = root("shell");
1191 let action = ServiceDeploymentAction::Command {
1192 program: "/bin/sh".to_owned(),
1193 args: vec!["-c".to_owned(), "exit 0".to_owned()],
1194 working_directory: None,
1195 };
1196 let error = execute_service_action(&root, &effect(action.clone()), &action).unwrap_err();
1197 assert!(error.to_string().contains("shell programs"));
1198 fs::remove_dir_all(root).unwrap();
1199 }
1200}