Skip to main content

platform_module_management/
lib.rs

1//! Console HTTP adapter for the shared Module management kernel.
2//! The adapter accepts business changes and returns kernel-owned snapshots or
3//! immutable plans; it never reimplements resolution, Cargo, or workspace rules.
4
5mod 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}