1use platform_admin_data::{
22 AdminModule, AdminModuleMetadata, AdminModuleSourceDiagnostics, AdminRemoteModuleDiagnostics,
23};
24use platform_core::error::ErrorDetail;
25use platform_core::{
26 ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
27 PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
28 RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
29};
30use platform_http::ApiOpenApiRouter;
31pub use platform_module::HostLinkedModule;
32use platform_module::{
33 AdminSchema, AdminSurface, EventHandlerRegistrationContext, LifecycleActivationRunPolicy,
34 LifecycleStartupCheckKind, LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus,
35 ModuleManifest, ModuleSource,
36};
37use platform_module_remote::{RemoteHttpProxyRegistry, RemoteModuleConfig, RemoteModuleSource};
38use platform_runtime::{
39 EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
40};
41use std::fs::{self, OpenOptions};
42use std::io::Write as _;
43use std::path::{Path, PathBuf};
44use std::process::{Child, Command};
45use std::sync::Arc;
46use std::thread;
47use std::time::{Duration, Instant};
48
49const DEFAULT_MODULE_SERVICES_FILE: &str = ".lenso/module-services.json";
50const DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS: u64 = 10_000;
51const REMOTE_SERVICE_TERMINATE_GRACE_MS: u64 = 800;
52const AUTH_SESSION_CACHE_MAX_TTL: Duration = Duration::from_secs(12 * 60 * 60);
53
54struct LinkedModuleEntry {
55 module_name: &'static str,
56 manifest: fn() -> ModuleManifest,
57 load: fn(&AppContext) -> Module,
58 http_binding: Option<fn() -> LinkedBinding>,
59}
60
61const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
62 id: "modules",
63 label: "Modules",
64 description: "Module load toggles applied on service startup.",
65 order: 10,
66};
67
68#[derive(Debug, Clone, Default)]
69pub struct HostComposition {
70 linked_modules: Vec<HostLinkedModule>,
71}
72
73impl HostComposition {
74 #[must_use]
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 #[must_use]
80 pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
81 self.add_linked_module(module);
82 self
83 }
84
85 pub fn add_linked_module(&mut self, module: HostLinkedModule) {
86 self.linked_modules.push(module);
87 }
88
89 #[must_use]
90 pub fn linked_modules(&self) -> &[HostLinkedModule] {
91 &self.linked_modules
92 }
93}
94
95#[derive(Debug, Clone)]
96pub struct HostWiring {
97 auth_session_policy: auth::session_policy::AuthSessionPolicyHandle,
98}
99
100impl HostWiring {
101 #[must_use]
102 pub fn auth_session_policy(&self) -> auth::session_policy::AuthSessionPolicyHandle {
103 self.auth_session_policy.clone()
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum CompositionProfile {
109 Core,
110 Demo,
111}
112
113impl CompositionProfile {
114 pub fn parse(value: &str) -> platform_core::AppResult<Self> {
115 match value.trim().to_ascii_lowercase().as_str() {
116 "core" => Ok(Self::Core),
117 "demo" => Ok(Self::Demo),
118 other => Err(AppError::validation(
119 "Invalid Lenso composition profile",
120 vec![ErrorDetail {
121 field: Some("module_sources.linked_profile".to_owned()),
122 reason: format!("expected `core` or `demo`, got `{other}`"),
123 }],
124 )),
125 }
126 }
127
128 pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
129 Self::parse(&config.module_sources.linked_profile)
130 }
131}
132
133impl Default for CompositionProfile {
134 fn default() -> Self {
135 Self::Demo
136 }
137}
138
139const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[LinkedModuleEntry {
140 module_name: "platform-story",
141 manifest: story::module::manifest,
142 load: story::module::module,
143 http_binding: Some(story::module::binding),
144}];
145
146const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
147 LinkedModuleEntry {
148 module_name: "auth",
149 manifest: auth::module::manifest,
150 load: auth::module::module,
151 http_binding: Some(auth::module::binding),
152 },
153 LinkedModuleEntry {
154 module_name: "auth-password",
155 manifest: auth_password::module::manifest,
156 load: auth_password::module::module,
157 http_binding: Some(auth_password::module::binding),
158 },
159 LinkedModuleEntry {
160 module_name: "platform-story",
161 manifest: story::module::manifest,
162 load: story::module::module,
163 http_binding: Some(story::module::binding),
164 },
165];
166
167fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
168 match profile {
169 CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
170 CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
171 }
172}
173
174#[must_use]
175pub fn auth_linked_module() -> HostLinkedModule {
176 HostLinkedModule::linked(
177 auth::module::MODULE_NAME,
178 auth::module::manifest,
179 auth::module::module,
180 auth::migrations::AUTH_MIGRATIONS,
181 )
182 .with_http_binding(auth::module::binding)
183}
184
185#[must_use]
186pub fn auth_password_linked_module() -> HostLinkedModule {
187 HostLinkedModule::linked(
188 auth_password::module::MODULE_NAME,
189 auth_password::module::manifest,
190 auth_password::module::module,
191 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
192 )
193 .with_http_binding(auth_password::module::binding)
194}
195
196fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
197 config
198 .modules
199 .get(module_name)
200 .is_none_or(platform_core::ModuleConfig::is_enabled)
201}
202
203fn module_enabled_config_key(module_name: &str) -> String {
204 format!("modules.{module_name}.enabled")
205}
206
207fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
208 ctx.runtime_config
209 .snapshot()
210 .raw(&module_enabled_config_key(module_name))
211 .and_then(serde_json::Value::as_bool)
212 .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
213}
214
215fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
216 (manifest)()
217 .dependencies
218 .into_iter()
219 .find(|dependency| !linked_module_enabled(ctx, dependency))
220}
221
222fn first_disabled_dependency_from_config(
223 config: &platform_core::AppConfig,
224 manifest: fn() -> ModuleManifest,
225) -> Option<String> {
226 (manifest)()
227 .dependencies
228 .into_iter()
229 .find(|dependency| !linked_module_enabled_from_config(config, dependency))
230}
231
232fn linked_module_with_dependencies_enabled(
233 ctx: &AppContext,
234 module_name: &str,
235 manifest: fn() -> ModuleManifest,
236) -> bool {
237 linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
238}
239
240fn linked_module_with_dependencies_enabled_from_config(
241 config: &platform_core::AppConfig,
242 module_name: &str,
243 manifest: fn() -> ModuleManifest,
244) -> bool {
245 linked_module_enabled_from_config(config, module_name)
246 && first_disabled_dependency_from_config(config, manifest).is_none()
247}
248
249fn linked_module_disabled_reason(
250 ctx: &AppContext,
251 module_name: &str,
252 manifest: fn() -> ModuleManifest,
253) -> Option<String> {
254 if !linked_module_enabled(ctx, module_name) {
255 return Some("module disabled by configuration".to_owned());
256 }
257 if let Some(dependency) = first_disabled_dependency(ctx, manifest) {
258 return Some(format!("module dependency disabled: {dependency}"));
259 }
260 None
261}
262
263fn remote_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
264 config
265 .modules
266 .get(module_name)
267 .is_none_or(platform_core::ModuleConfig::is_enabled)
268}
269
270fn remote_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
271 ctx.runtime_config
272 .snapshot()
273 .raw(&module_enabled_config_key(module_name))
274 .and_then(serde_json::Value::as_bool)
275 .unwrap_or_else(|| remote_module_enabled_from_config(&ctx.config, module_name))
276}
277
278pub fn auth_actor_resolver_for_context(
279 ctx: &AppContext,
280) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
281 auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
282}
283
284pub fn auth_actor_resolver_for_context_with_composition(
285 ctx: &AppContext,
286 composition: &HostComposition,
287) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
288 let profile = CompositionProfile::from_config(&ctx.config)?;
289 let auth_in_profile = linked_module_entries(profile)
290 .iter()
291 .any(|entry| entry.module_name == auth::module::MODULE_NAME);
292 let auth_in_composition = composition
293 .linked_modules()
294 .iter()
295 .any(|entry| entry.module_name == auth::module::MODULE_NAME);
296 if (!auth_in_profile && !auth_in_composition)
297 || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
298 {
299 return Ok(None);
300 }
301
302 let auth_resolver: Arc<dyn platform_core::ActorResolver> =
303 Arc::new(auth::resolver::AuthActorResolver::new_with_session_cache(
304 ctx.db.clone(),
305 ctx.actor_resolver.clone(),
306 auth_session_cache(ctx)?,
307 ));
308
309 let auth_password_enabled = linked_module_with_dependencies_enabled(
310 ctx,
311 auth_password::module::MODULE_NAME,
312 auth_password::module::manifest,
313 );
314 if auth_password_enabled {
315 if let Some(jwt_resolver) =
316 auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
317 {
318 return Ok(Some(jwt_resolver));
319 }
320 }
321
322 Ok(Some(auth_resolver))
323}
324
325fn auth_session_cache(
326 ctx: &AppContext,
327) -> platform_core::AppResult<Option<Arc<dyn auth::resolver::SessionCache>>> {
328 match auth::config::AuthRuntimeConfig::from_context(ctx).session_cache {
329 auth::config::SessionCacheMode::Database => Ok(None),
330 auth::config::SessionCacheMode::Redis => {
331 let Some(redis) = ctx.redis.clone() else {
332 return Err(AppError::validation(
333 "Redis auth session cache is not configured",
334 vec![ErrorDetail {
335 field: Some("auth.session_cache".to_owned()),
336 reason: "set REDIS_URL when auth.session_cache is redis".to_owned(),
337 }],
338 ));
339 };
340 Ok(Some(Arc::new(auth::redis_cache::RedisSessionCache::new(
341 redis,
342 AUTH_SESSION_CACHE_MAX_TTL,
343 ))))
344 }
345 }
346}
347
348fn linked_module_entries_for_context(
349 ctx: &AppContext,
350) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
351 Ok(
352 linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
353 .iter()
354 .filter(|entry| {
355 linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
356 })
357 .collect(),
358 )
359}
360
361fn linked_module_entries_for_config(
362 config: &platform_core::AppConfig,
363) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
364 Ok(
365 linked_module_entries(CompositionProfile::from_config(config)?)
366 .iter()
367 .filter(|entry| {
368 linked_module_with_dependencies_enabled_from_config(
369 config,
370 entry.module_name,
371 entry.manifest,
372 )
373 })
374 .collect(),
375 )
376}
377
378fn disabled_linked_module_entries_for_context(
379 ctx: &AppContext,
380) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
381 Ok(
382 linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
383 .iter()
384 .filter(|entry| {
385 linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
386 })
387 .collect(),
388 )
389}
390
391fn host_linked_modules_for_config(
392 config: &platform_core::AppConfig,
393 composition: &HostComposition,
394) -> Vec<HostLinkedModule> {
395 composition
396 .linked_modules()
397 .iter()
398 .cloned()
399 .filter(|entry| {
400 linked_module_with_dependencies_enabled_from_config(
401 config,
402 entry.module_name,
403 entry.manifest,
404 )
405 })
406 .collect()
407}
408
409fn host_linked_modules_for_context(
410 ctx: &AppContext,
411 composition: &HostComposition,
412) -> Vec<HostLinkedModule> {
413 composition
414 .linked_modules()
415 .iter()
416 .cloned()
417 .filter(|entry| {
418 linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
419 })
420 .collect()
421}
422
423fn disabled_host_linked_modules_for_context(
424 ctx: &AppContext,
425 composition: &HostComposition,
426) -> Vec<HostLinkedModule> {
427 composition
428 .linked_modules()
429 .iter()
430 .cloned()
431 .filter(|entry| {
432 linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
433 })
434 .collect()
435}
436
437pub fn host_wiring_for_context(ctx: &AppContext) -> platform_core::AppResult<HostWiring> {
438 host_wiring_for_context_with_composition(ctx, &HostComposition::default())
439}
440
441pub fn host_wiring_for_context_with_composition(
442 ctx: &AppContext,
443 composition: &HostComposition,
444) -> platform_core::AppResult<HostWiring> {
445 let mut session_policies = Vec::new();
446 for module in host_linked_modules_for_context(ctx, composition) {
447 for extension in module.contributions::<auth::session_policy::AuthHostExtension>() {
448 if let Some(factory) = extension.session_policy_factory() {
449 session_policies.push(factory(ctx));
450 }
451 }
452 }
453
454 Ok(HostWiring {
455 auth_session_policy: auth::session_policy::AuthSessionPolicyChain::handle(session_policies),
456 })
457}
458
459fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
460 match entry.load {
461 Some(load) => load(ctx),
462 None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
463 }
464}
465
466#[must_use]
471pub fn modules(ctx: &AppContext) -> Vec<Module> {
472 modules_for_profile(ctx, CompositionProfile::default())
473}
474
475pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
476 Ok(linked_module_entries_for_context(ctx)?
477 .into_iter()
478 .map(|entry| (entry.load)(ctx))
479 .collect())
480}
481
482pub fn modules_for_config_with_composition(
483 ctx: &AppContext,
484 composition: &HostComposition,
485) -> platform_core::AppResult<Vec<Module>> {
486 let mut modules = modules_for_config(ctx)?;
487 modules.extend(
488 host_linked_modules_for_context(ctx, composition)
489 .into_iter()
490 .map(|entry| load_host_linked_module(ctx, entry)),
491 );
492 Ok(modules)
493}
494
495#[must_use]
496pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
497 linked_module_entries(profile)
498 .iter()
499 .map(|entry| (entry.load)(ctx))
500 .collect()
501}
502
503pub async fn load_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
509 load_modules_with_composition(ctx, &HostComposition::default()).await
510}
511
512pub async fn load_modules_with_composition(
513 ctx: &AppContext,
514 composition: &HostComposition,
515) -> platform_core::AppResult<Vec<Module>> {
516 let mut loaded = modules_for_config_with_composition(ctx, composition)?;
517
518 for remote in &ctx.config.module_sources.remote {
519 if !remote_module_enabled(ctx, &remote.name) {
520 continue;
521 }
522 let source = RemoteModuleSource::new(remote_module_config(remote))?;
523 loaded.push(source.load().await?);
524 }
525
526 Ok(loaded)
527}
528
529pub fn migrations_for_config(
530 config: &platform_core::AppConfig,
531) -> platform_core::AppResult<Vec<Migration>> {
532 migrations_for_config_with_composition(config, &HostComposition::default())
533}
534
535pub fn migrations_for_config_with_composition(
536 config: &platform_core::AppConfig,
537 composition: &HostComposition,
538) -> platform_core::AppResult<Vec<Migration>> {
539 let mut migrations = PLATFORM_MIGRATIONS
540 .iter()
541 .chain(RUNTIME_MIGRATIONS)
542 .copied()
543 .collect::<Vec<_>>();
544
545 if CompositionProfile::from_config(config)? == CompositionProfile::Demo {
546 if linked_module_enabled_from_config(config, "auth") {
547 migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
548 }
549 if linked_module_with_dependencies_enabled_from_config(
550 config,
551 "auth-password",
552 auth_password::module::manifest,
553 ) {
554 migrations.extend(
555 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
556 .iter()
557 .copied(),
558 );
559 }
560 }
561
562 for module in host_linked_modules_for_config(config, composition) {
563 migrations.extend(module.migrations.iter().copied());
564 }
565
566 Ok(migrations)
567}
568
569#[must_use]
570pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
571 let mut migrations = PLATFORM_MIGRATIONS
572 .iter()
573 .chain(RUNTIME_MIGRATIONS)
574 .copied()
575 .collect::<Vec<_>>();
576
577 if profile == CompositionProfile::Demo {
578 migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
579 migrations.extend(
580 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
581 .iter()
582 .copied(),
583 );
584 }
585
586 migrations
587}
588
589#[must_use]
592pub fn module_manifests() -> Vec<ModuleManifest> {
593 module_manifests_for_profile(CompositionProfile::default())
594}
595
596#[must_use]
597pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
598 linked_module_entries(profile)
599 .iter()
600 .map(|entry| (entry.manifest)())
601 .collect()
602}
603
604#[must_use]
606pub fn linked_runtime_function_declaration_sources() -> Vec<(
607 String,
608 ModuleSource,
609 Option<platform_module::RuntimeSurface>,
610)> {
611 linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
612}
613
614#[must_use]
615pub fn linked_runtime_function_declaration_sources_for_profile(
616 profile: CompositionProfile,
617) -> Vec<(
618 String,
619 ModuleSource,
620 Option<platform_module::RuntimeSurface>,
621)> {
622 module_manifests_for_profile(profile)
623 .into_iter()
624 .map(|manifest| (manifest.name, ModuleSource::Linked, manifest.runtime))
625 .collect()
626}
627
628pub fn linked_runtime_function_declaration_sources_for_config(
629 config: &platform_core::AppConfig,
630) -> platform_core::AppResult<
631 Vec<(
632 String,
633 ModuleSource,
634 Option<platform_module::RuntimeSurface>,
635 )>,
636> {
637 Ok(linked_module_entries_for_config(config)?
638 .into_iter()
639 .map(|entry| {
640 let manifest = (entry.manifest)();
641 (manifest.name, ModuleSource::Linked, manifest.runtime)
642 })
643 .collect())
644}
645
646pub fn linked_runtime_function_declaration_sources_for_context(
647 ctx: &AppContext,
648) -> platform_core::AppResult<
649 Vec<(
650 String,
651 ModuleSource,
652 Option<platform_module::RuntimeSurface>,
653 )>,
654> {
655 Ok(linked_module_entries_for_context(ctx)?
656 .into_iter()
657 .map(|entry| {
658 let manifest = (entry.manifest)();
659 (manifest.name, ModuleSource::Linked, manifest.runtime)
660 })
661 .collect())
662}
663
664pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
665 ctx: &AppContext,
666 composition: &HostComposition,
667) -> platform_core::AppResult<
668 Vec<(
669 String,
670 ModuleSource,
671 Option<platform_module::RuntimeSurface>,
672 )>,
673> {
674 let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
675 sources.extend(
676 host_linked_modules_for_context(ctx, composition)
677 .into_iter()
678 .map(|entry| {
679 let manifest = (entry.manifest)();
680 (manifest.name, ModuleSource::Linked, manifest.runtime)
681 }),
682 );
683 Ok(sources)
684}
685
686#[must_use]
689pub fn runtime_function_declaration_sources_from_metadata(
690 modules: &[AdminModuleMetadata],
691) -> Vec<(
692 String,
693 ModuleSource,
694 Option<platform_module::RuntimeSurface>,
695)> {
696 modules
697 .iter()
698 .filter(|module| matches!(module.load_status, ModuleLoadStatus::Loaded))
699 .map(|module| {
700 (
701 module.module_name.clone(),
702 module.source,
703 module.runtime.clone(),
704 )
705 })
706 .collect()
707}
708
709#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct LinkedHttpRouteOwner {
715 pub module_name: String,
716 pub public_prefixes: &'static [&'static str],
717}
718
719#[must_use]
720pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
721 linked_http_route_owners_for_profile(CompositionProfile::default())
722}
723
724#[must_use]
725pub fn linked_http_route_owners_for_profile(
726 profile: CompositionProfile,
727) -> Vec<LinkedHttpRouteOwner> {
728 linked_module_entries(profile)
729 .iter()
730 .filter_map(|entry| {
731 let http = entry.http_binding?().http?;
732 Some(LinkedHttpRouteOwner {
733 module_name: entry.module_name.to_owned(),
734 public_prefixes: http.public_prefixes,
735 })
736 })
737 .collect()
738}
739
740#[must_use]
742pub fn linked_http_modules() -> Vec<Module> {
743 linked_http_modules_for_profile(CompositionProfile::default())
744}
745
746#[must_use]
747pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
748 linked_module_entries(profile)
749 .iter()
750 .filter_map(|entry| {
751 let http_binding = entry.http_binding?;
752 Some(Module::linked((entry.manifest)(), http_binding()))
753 })
754 .collect()
755}
756
757pub fn linked_http_modules_for_config(
758 config: &platform_core::AppConfig,
759) -> platform_core::AppResult<Vec<Module>> {
760 Ok(linked_module_entries_for_config(config)?
761 .into_iter()
762 .filter_map(|entry| {
763 let http_binding = entry.http_binding?;
764 Some(Module::linked((entry.manifest)(), http_binding()))
765 })
766 .collect())
767}
768
769pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
770 Ok(linked_module_entries_for_context(ctx)?
771 .into_iter()
772 .filter_map(|entry| {
773 let http_binding = entry.http_binding?;
774 Some(Module::linked((entry.manifest)(), http_binding()))
775 })
776 .collect())
777}
778
779pub fn linked_http_modules_for_context_with_composition(
780 ctx: &AppContext,
781 composition: &HostComposition,
782) -> platform_core::AppResult<Vec<Module>> {
783 let mut modules = linked_http_modules_for_context(ctx)?;
784 modules.extend(
785 host_linked_modules_for_context(ctx, composition)
786 .into_iter()
787 .filter_map(|entry| {
788 let http_binding = entry.http_binding?;
789 Some(Module::linked((entry.manifest)(), http_binding()))
790 }),
791 );
792 Ok(modules)
793}
794
795#[must_use]
800pub fn admin_modules(ctx: &AppContext) -> Vec<AdminModule> {
801 admin_modules_from_modules(modules(ctx))
802}
803
804pub async fn load_admin_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<AdminModule>> {
806 load_admin_modules_with_composition(ctx, &HostComposition::default()).await
807}
808
809pub async fn load_admin_modules_with_composition(
810 ctx: &AppContext,
811 composition: &HostComposition,
812) -> platform_core::AppResult<Vec<AdminModule>> {
813 let mut admin_modules =
814 admin_modules_from_modules(modules_for_config_with_composition(ctx, composition)?);
815
816 for remote in &ctx.config.module_sources.remote {
817 if !remote_module_enabled(ctx, &remote.name) {
818 continue;
819 }
820 let source = RemoteModuleSource::new(remote_module_config(remote))?;
821 match source.load().await {
822 Ok(module) => admin_modules.extend(admin_modules_from_modules(vec![module])),
823 Err(error) => admin_modules.push(failed_remote_admin_module(
824 remote.name.clone(),
825 error.public_message,
826 )),
827 }
828 }
829
830 Ok(admin_modules)
831}
832
833pub async fn load_admin_module_metadata(
837 ctx: &AppContext,
838) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
839 load_admin_module_metadata_with_composition(ctx, &HostComposition::default()).await
840}
841
842pub async fn load_admin_module_metadata_with_composition(
843 ctx: &AppContext,
844 composition: &HostComposition,
845) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
846 let mut metadata =
847 admin_metadata_from_modules(modules_for_config_with_composition(ctx, composition)?);
848 metadata.extend(disabled_linked_admin_metadata(ctx)?);
849 metadata.extend(disabled_host_linked_admin_metadata(ctx, composition));
850
851 for remote in &ctx.config.module_sources.remote {
852 let config = remote_module_config(remote);
853 if !remote_module_enabled(ctx, &remote.name) {
854 metadata.push(disabled_remote_admin_metadata(&config));
855 continue;
856 }
857 let checked_at = current_timestamp();
858 let source = RemoteModuleSource::new(config.clone())?;
859 let load_started = Instant::now();
860 match source.load().await {
861 Ok(module) => metadata.extend(remote_admin_metadata_from_module(
862 module,
863 &config,
864 checked_at,
865 Some(duration_ms(load_started)),
866 None,
867 )),
868 Err(error) => metadata.push(failed_remote_admin_metadata(
869 &config,
870 Some(checked_at),
871 Some(duration_ms(load_started)),
872 error.public_message,
873 )),
874 }
875 }
876
877 Ok(metadata)
878}
879
880pub async fn load_remote_http_proxy_registry(
881 ctx: &AppContext,
882) -> platform_core::AppResult<RemoteHttpProxyRegistry> {
883 let mut remote_modules = Vec::new();
884 let mut remote_configs = Vec::new();
885
886 for remote in &ctx.config.module_sources.remote {
887 if !remote_module_enabled(ctx, &remote.name) {
888 continue;
889 }
890 let config = remote_module_config(remote);
891 let source = RemoteModuleSource::new(config.clone())?;
892 if let Ok(module) = source.load().await {
893 remote_modules.push(module);
894 remote_configs.push(config);
895 }
896 }
897
898 Ok(RemoteHttpProxyRegistry::from_modules(
899 &remote_modules,
900 &remote_configs,
901 ))
902}
903
904fn admin_modules_from_modules(modules: Vec<Module>) -> Vec<AdminModule> {
905 modules
906 .into_iter()
907 .filter_map(|module| {
908 let data_source = module.admin_data;
910 let action_source = module.admin_actions;
911 let query_source = module.admin_queries;
912 if data_source.is_none() && action_source.is_none() && query_source.is_none() {
913 return None;
914 }
915 let ModuleManifest { name, admin, .. } = module.manifest;
916 let admin = admin?;
917 let (schema, listed_in_schema) = match &admin {
918 AdminSurface::Schema(schema) => (schema.clone(), true),
919 AdminSurface::DeclarativeCustom(surface) => (
920 surface.fallback_schema.clone().unwrap_or(AdminSchema {
921 entities: Vec::new(),
922 }),
923 false,
924 ),
925 AdminSurface::EmbeddedCustom(_) => return None,
926 _ => return None,
927 };
928 Some(AdminModule {
929 module_name: name,
930 source: module.source,
931 load_status: module.load_status,
932 schema,
933 admin: Some(admin),
934 listed_in_schema,
935 data_source,
936 action_source,
937 query_source,
938 })
939 })
940 .collect()
941}
942
943fn admin_metadata_from_modules(modules: Vec<Module>) -> Vec<AdminModuleMetadata> {
944 modules
945 .into_iter()
946 .map(|module| {
947 let ModuleManifest {
948 name,
949 admin,
950 http_routes,
951 runtime,
952 events,
953 lifecycle,
954 console,
955 story_display,
956 capabilities,
957 dependencies,
958 ..
959 } = module.manifest;
960 AdminModuleMetadata {
961 module_name: name,
962 source: module.source,
963 load_status: module.load_status,
964 http_routes,
965 runtime,
966 events,
967 lifecycle,
968 console,
969 story_display,
970 capabilities,
971 dependencies,
972 admin,
973 source_diagnostics: None,
974 }
975 })
976 .collect()
977}
978
979fn failed_remote_admin_module(name: String, message: String) -> AdminModule {
980 AdminModule {
981 module_name: name,
982 source: ModuleSource::Remote,
983 load_status: ModuleLoadStatus::Error { message },
984 schema: AdminSchema {
985 entities: Vec::new(),
986 },
987 admin: None,
988 listed_in_schema: true,
989 data_source: None,
990 action_source: None,
991 query_source: None,
992 }
993}
994
995fn remote_admin_metadata_from_module(
996 module: Module,
997 config: &RemoteModuleConfig,
998 checked_at: String,
999 load_duration_ms: Option<u64>,
1000 load_error: Option<String>,
1001) -> Vec<AdminModuleMetadata> {
1002 admin_metadata_from_modules(vec![module])
1003 .into_iter()
1004 .map(|mut metadata| {
1005 metadata.source_diagnostics = Some(remote_source_diagnostics(
1006 config,
1007 Some(checked_at.clone()),
1008 load_duration_ms,
1009 load_error.clone(),
1010 ));
1011 metadata
1012 })
1013 .collect()
1014}
1015
1016fn failed_remote_admin_metadata(
1017 config: &RemoteModuleConfig,
1018 checked_at: Option<String>,
1019 load_duration_ms: Option<u64>,
1020 message: String,
1021) -> AdminModuleMetadata {
1022 AdminModuleMetadata {
1023 module_name: config.name.clone(),
1024 source: ModuleSource::Remote,
1025 load_status: ModuleLoadStatus::Error {
1026 message: message.clone(),
1027 },
1028 http_routes: Vec::new(),
1029 runtime: None,
1030 events: None,
1031 lifecycle: None,
1032 console: Vec::new(),
1033 story_display: Vec::new(),
1034 capabilities: Vec::new(),
1035 dependencies: Vec::new(),
1036 admin: None,
1037 source_diagnostics: Some(remote_source_diagnostics(
1038 config,
1039 checked_at,
1040 load_duration_ms,
1041 Some(message),
1042 )),
1043 }
1044}
1045
1046fn disabled_remote_admin_metadata(config: &RemoteModuleConfig) -> AdminModuleMetadata {
1047 AdminModuleMetadata {
1048 module_name: config.name.clone(),
1049 source: ModuleSource::Remote,
1050 load_status: ModuleLoadStatus::Error {
1051 message: "module disabled by configuration".to_owned(),
1052 },
1053 http_routes: Vec::new(),
1054 runtime: None,
1055 events: None,
1056 lifecycle: None,
1057 console: Vec::new(),
1058 story_display: Vec::new(),
1059 capabilities: Vec::new(),
1060 dependencies: Vec::new(),
1061 admin: None,
1062 source_diagnostics: Some(remote_source_diagnostics(config, None, None, None)),
1063 }
1064}
1065
1066fn disabled_linked_admin_metadata(
1067 ctx: &AppContext,
1068) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1069 Ok(disabled_linked_module_entries_for_context(ctx)?
1070 .into_iter()
1071 .map(|entry| {
1072 let ModuleManifest {
1073 name,
1074 admin,
1075 http_routes,
1076 runtime,
1077 events,
1078 lifecycle,
1079 console,
1080 story_display,
1081 capabilities,
1082 dependencies,
1083 ..
1084 } = (entry.manifest)();
1085 AdminModuleMetadata {
1086 module_name: name,
1087 source: ModuleSource::Linked,
1088 load_status: ModuleLoadStatus::Error {
1089 message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1090 .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1091 },
1092 http_routes,
1093 runtime,
1094 events,
1095 lifecycle,
1096 console,
1097 story_display,
1098 capabilities,
1099 dependencies,
1100 admin,
1101 source_diagnostics: None,
1102 }
1103 })
1104 .collect())
1105}
1106
1107fn disabled_host_linked_admin_metadata(
1108 ctx: &AppContext,
1109 composition: &HostComposition,
1110) -> Vec<AdminModuleMetadata> {
1111 disabled_host_linked_modules_for_context(ctx, composition)
1112 .into_iter()
1113 .map(|entry| {
1114 let ModuleManifest {
1115 name,
1116 admin,
1117 http_routes,
1118 runtime,
1119 events,
1120 lifecycle,
1121 console,
1122 story_display,
1123 capabilities,
1124 dependencies,
1125 ..
1126 } = (entry.manifest)();
1127 AdminModuleMetadata {
1128 module_name: name,
1129 source: ModuleSource::Linked,
1130 load_status: ModuleLoadStatus::Error {
1131 message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1132 .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1133 },
1134 http_routes,
1135 runtime,
1136 events,
1137 lifecycle,
1138 console,
1139 story_display,
1140 capabilities,
1141 dependencies,
1142 admin,
1143 source_diagnostics: None,
1144 }
1145 })
1146 .collect()
1147}
1148
1149fn remote_source_diagnostics(
1150 config: &RemoteModuleConfig,
1151 checked_at: Option<String>,
1152 load_duration_ms: Option<u64>,
1153 load_error: Option<String>,
1154) -> AdminModuleSourceDiagnostics {
1155 let (transport, manifest_url) = match config.transport {
1156 platform_module_remote::RemoteModuleTransport::HttpJson => {
1157 ("http_json", format!("{}/manifest", config.base_url))
1158 }
1159 platform_module_remote::RemoteModuleTransport::Grpc => (
1160 "grpc",
1161 format!(
1162 "{}#lenso.remote.v1.RemoteModule/GetManifest",
1163 config.base_url
1164 ),
1165 ),
1166 };
1167 AdminModuleSourceDiagnostics::Remote(AdminRemoteModuleDiagnostics {
1168 transport: transport.to_owned(),
1169 base_url: config.base_url.clone(),
1170 manifest_url,
1171 timeout_ms: config.timeout_ms,
1172 auth_configured: config.auth_token.is_some(),
1173 load_duration_ms,
1174 last_checked_at: checked_at,
1175 last_load_error: load_error,
1176 })
1177}
1178
1179fn remote_module_config(source: &platform_core::RemoteModuleSourceConfig) -> RemoteModuleConfig {
1180 let mut config = RemoteModuleConfig::new(source.name.clone(), source.base_url.clone())
1181 .with_timeout_ms(source.timeout_ms);
1182
1183 if let Some(env_name) = &source.auth_token_env {
1184 if let Ok(token) = std::env::var(env_name) {
1185 config = config.with_auth_token(token);
1186 }
1187 }
1188
1189 config
1190}
1191
1192#[derive(Debug)]
1193pub struct RemoteModuleServiceSupervisor {
1194 services: Vec<RemoteModuleServiceHandle>,
1195}
1196
1197impl RemoteModuleServiceSupervisor {
1198 #[must_use]
1199 pub fn is_empty(&self) -> bool {
1200 self.services.is_empty()
1201 }
1202}
1203
1204impl Drop for RemoteModuleServiceSupervisor {
1205 fn drop(&mut self) {
1206 for service in &mut self.services {
1207 terminate_remote_module_service(&mut service.child);
1208 release_remote_module_service_state(&service.lock_file_path, &service.pid_file_path);
1209 }
1210 }
1211}
1212
1213#[derive(Debug)]
1214struct RemoteModuleServiceHandle {
1215 child: Child,
1216 lock_file_path: PathBuf,
1217 pid_file_path: PathBuf,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1221struct RemoteModuleServiceSpec {
1222 module_name: String,
1223 service_name: String,
1224 command: String,
1225 cwd: Option<PathBuf>,
1226 ready_url: String,
1227 ready_timeout_ms: u64,
1228 auto_start: bool,
1229}
1230
1231pub async fn start_installed_remote_module_services(
1232 ctx: &AppContext,
1233) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1234 start_installed_remote_module_services_from_path(ctx, Path::new(DEFAULT_MODULE_SERVICES_FILE))
1235 .await
1236}
1237
1238pub async fn start_installed_remote_module_services_from_path(
1239 ctx: &AppContext,
1240 services_file_path: &Path,
1241) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1242 let specs = read_remote_module_service_specs(services_file_path)?;
1243 let services_state_dir = services_file_path
1244 .parent()
1245 .unwrap_or_else(|| Path::new("."));
1246 let client = reqwest::Client::builder()
1247 .timeout(Duration::from_millis(800))
1248 .build()
1249 .map_err(|source| {
1250 AppError::new(ErrorCode::Internal, "failed to build HTTP client").with_source(source)
1251 })?;
1252 let mut services = Vec::new();
1253
1254 for spec in specs {
1255 if !spec.auto_start || !remote_service_module_enabled(ctx, &spec.module_name) {
1256 continue;
1257 }
1258 if remote_service_ready(&client, &spec.ready_url).await {
1259 tracing::info!(
1260 module = %spec.module_name,
1261 service = %spec.service_name,
1262 ready_url = %spec.ready_url,
1263 "remote module service already ready"
1264 );
1265 continue;
1266 }
1267 let lock_file_path = remote_module_service_state_path(services_state_dir, &spec, "lock");
1268 let pid_file_path = remote_module_service_state_path(services_state_dir, &spec, "pid");
1269 if !claim_remote_module_service_lock(&client, &spec, &lock_file_path, &pid_file_path)
1270 .await?
1271 {
1272 continue;
1273 }
1274 let mut child = match spawn_remote_module_service(&spec) {
1275 Ok(child) => child,
1276 Err(error) => {
1277 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1278 return Err(error);
1279 }
1280 };
1281 if let Err(error) = write_remote_module_service_pid(&pid_file_path, child.id()) {
1282 terminate_remote_module_service(&mut child);
1283 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1284 return Err(error);
1285 }
1286 if let Err(error) = wait_for_remote_module_service(&client, &spec, &mut child).await {
1287 terminate_remote_module_service(&mut child);
1288 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1289 return Err(error);
1290 }
1291 tracing::info!(
1292 module = %spec.module_name,
1293 service = %spec.service_name,
1294 ready_url = %spec.ready_url,
1295 "started remote module service"
1296 );
1297 services.push(RemoteModuleServiceHandle {
1298 child,
1299 lock_file_path,
1300 pid_file_path,
1301 });
1302 }
1303
1304 Ok(RemoteModuleServiceSupervisor { services })
1305}
1306
1307fn remote_service_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
1308 ctx.config
1309 .module_sources
1310 .remote
1311 .iter()
1312 .any(|remote| remote.name == module_name)
1313 && remote_module_enabled(ctx, module_name)
1314}
1315
1316fn read_remote_module_service_specs(
1317 services_file_path: &Path,
1318) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1319 let source = match std::fs::read_to_string(services_file_path) {
1320 Ok(source) => source,
1321 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1322 Err(source) => {
1323 return Err(AppError::new(
1324 ErrorCode::ExternalDependency,
1325 format!("remote module services file could not be read: {source}"),
1326 ));
1327 }
1328 };
1329 let value = serde_json::from_str::<serde_json::Value>(&source).map_err(|source| {
1330 AppError::new(
1331 ErrorCode::Validation,
1332 format!("remote module services file could not be parsed: {source}"),
1333 )
1334 })?;
1335 parse_remote_module_service_specs(&value)
1336}
1337
1338fn parse_remote_module_service_specs(
1339 value: &serde_json::Value,
1340) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1341 let modules = value
1342 .get("modules")
1343 .and_then(serde_json::Value::as_array)
1344 .ok_or_else(|| {
1345 AppError::new(
1346 ErrorCode::Validation,
1347 "remote module services file modules must be an array",
1348 )
1349 })?;
1350 let mut specs = Vec::new();
1351 for module in modules {
1352 let module_name = json_string(module, "moduleName")?;
1353 let services = module
1354 .get("services")
1355 .and_then(serde_json::Value::as_array)
1356 .ok_or_else(|| {
1357 AppError::new(
1358 ErrorCode::Validation,
1359 format!("{module_name} services must be an array"),
1360 )
1361 })?;
1362 for service in services {
1363 let command = json_string(service, "command")?;
1364 let ready_url = json_string(service, "readyUrl")?;
1365 specs.push(RemoteModuleServiceSpec {
1366 module_name: module_name.clone(),
1367 service_name: service
1368 .get("name")
1369 .and_then(serde_json::Value::as_str)
1370 .unwrap_or(&module_name)
1371 .to_owned(),
1372 command,
1373 cwd: service
1374 .get("cwd")
1375 .and_then(serde_json::Value::as_str)
1376 .map(PathBuf::from),
1377 ready_url,
1378 ready_timeout_ms: service
1379 .get("readyTimeoutMs")
1380 .and_then(serde_json::Value::as_u64)
1381 .unwrap_or(DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS),
1382 auto_start: service
1383 .get("autoStart")
1384 .and_then(serde_json::Value::as_bool)
1385 .unwrap_or(true),
1386 });
1387 }
1388 }
1389 Ok(specs)
1390}
1391
1392fn json_string(value: &serde_json::Value, key: &str) -> platform_core::AppResult<String> {
1393 value
1394 .get(key)
1395 .and_then(serde_json::Value::as_str)
1396 .map(str::to_owned)
1397 .ok_or_else(|| AppError::new(ErrorCode::Validation, format!("{key} must be a string")))
1398}
1399
1400fn spawn_remote_module_service(spec: &RemoteModuleServiceSpec) -> platform_core::AppResult<Child> {
1401 let cwd = spec
1402 .cwd
1403 .clone()
1404 .unwrap_or(std::env::current_dir().map_err(|source| {
1405 AppError::new(ErrorCode::Internal, "failed to resolve current directory")
1406 .with_source(source)
1407 })?);
1408 let mut command = shell_command(&spec.command);
1409 command.current_dir(cwd);
1410 configure_remote_module_service_process(&mut command);
1411 command.spawn().map_err(|source| {
1412 AppError::new(
1413 ErrorCode::ExternalDependency,
1414 format!(
1415 "failed to start remote module service {}: {}",
1416 spec.module_name, spec.service_name
1417 ),
1418 )
1419 .with_source(source)
1420 })
1421}
1422
1423async fn wait_for_remote_module_service(
1424 client: &reqwest::Client,
1425 spec: &RemoteModuleServiceSpec,
1426 child: &mut Child,
1427) -> platform_core::AppResult<()> {
1428 let started = Instant::now();
1429 let timeout = Duration::from_millis(spec.ready_timeout_ms);
1430 loop {
1431 if remote_service_ready(client, &spec.ready_url).await {
1432 return Ok(());
1433 }
1434 if let Some(status) = child.try_wait().map_err(|source| {
1435 AppError::new(
1436 ErrorCode::ExternalDependency,
1437 format!(
1438 "remote module service {} status could not be checked",
1439 spec.service_name
1440 ),
1441 )
1442 .with_source(source)
1443 })? {
1444 return Err(AppError::new(
1445 ErrorCode::ExternalDependency,
1446 format!(
1447 "remote module service {} exited before it became ready: {status}",
1448 spec.service_name
1449 ),
1450 ));
1451 }
1452 if started.elapsed() >= timeout {
1453 return Err(AppError::new(
1454 ErrorCode::ExternalDependency,
1455 format!(
1456 "remote module service {} did not become ready at {}",
1457 spec.service_name, spec.ready_url
1458 ),
1459 ));
1460 }
1461 tokio::time::sleep(Duration::from_millis(200)).await;
1462 }
1463}
1464
1465async fn remote_service_ready(client: &reqwest::Client, ready_url: &str) -> bool {
1466 client
1467 .get(ready_url)
1468 .send()
1469 .await
1470 .is_ok_and(|response| response.status().is_success())
1471}
1472
1473async fn claim_remote_module_service_lock(
1474 client: &reqwest::Client,
1475 spec: &RemoteModuleServiceSpec,
1476 lock_file_path: &Path,
1477 pid_file_path: &Path,
1478) -> platform_core::AppResult<bool> {
1479 match create_remote_module_service_lock(lock_file_path) {
1480 Ok(()) => return Ok(true),
1481 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1482 Err(source) => {
1483 return Err(AppError::new(
1484 ErrorCode::ExternalDependency,
1485 format!(
1486 "remote module service {} lock could not be created: {source}",
1487 spec.service_name
1488 ),
1489 ));
1490 }
1491 }
1492
1493 tracing::info!(
1494 module = %spec.module_name,
1495 service = %spec.service_name,
1496 ready_url = %spec.ready_url,
1497 "remote module service startup already claimed"
1498 );
1499 if wait_for_remote_module_service_ready(
1500 client,
1501 &spec.ready_url,
1502 Duration::from_millis(spec.ready_timeout_ms),
1503 )
1504 .await
1505 {
1506 return Ok(false);
1507 }
1508
1509 tracing::warn!(
1510 module = %spec.module_name,
1511 service = %spec.service_name,
1512 lock_file = %lock_file_path.display(),
1513 "remote module service lock did not become ready before timeout; treating it as stale"
1514 );
1515 terminate_stale_remote_module_service(pid_file_path);
1516 release_remote_module_service_state(lock_file_path, pid_file_path);
1517 match create_remote_module_service_lock(lock_file_path) {
1518 Ok(()) => Ok(true),
1519 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1520 Err(source) => Err(AppError::new(
1521 ErrorCode::ExternalDependency,
1522 format!(
1523 "stale remote module service {} lock could not be replaced: {source}",
1524 spec.service_name
1525 ),
1526 )),
1527 }
1528}
1529
1530fn create_remote_module_service_lock(lock_file_path: &Path) -> std::io::Result<()> {
1531 if let Some(parent) = lock_file_path.parent() {
1532 fs::create_dir_all(parent)?;
1533 }
1534 let mut file = OpenOptions::new()
1535 .write(true)
1536 .create_new(true)
1537 .open(lock_file_path)?;
1538 writeln!(file, "owner_pid={}", std::process::id())?;
1539 Ok(())
1540}
1541
1542fn write_remote_module_service_pid(
1543 pid_file_path: &Path,
1544 child_pid: u32,
1545) -> platform_core::AppResult<()> {
1546 if let Some(parent) = pid_file_path.parent() {
1547 fs::create_dir_all(parent).map_err(|source| {
1548 AppError::new(
1549 ErrorCode::ExternalDependency,
1550 format!("remote module service pid directory could not be created: {source}"),
1551 )
1552 })?;
1553 }
1554 fs::write(pid_file_path, format!("{child_pid}\n")).map_err(|source| {
1555 AppError::new(
1556 ErrorCode::ExternalDependency,
1557 format!("remote module service pid file could not be written: {source}"),
1558 )
1559 })
1560}
1561
1562fn release_remote_module_service_state(lock_file_path: &Path, pid_file_path: &Path) {
1563 let _ = fs::remove_file(pid_file_path);
1564 let _ = fs::remove_file(lock_file_path);
1565}
1566
1567#[cfg(unix)]
1568fn terminate_stale_remote_module_service(pid_file_path: &Path) {
1569 let Ok(source) = fs::read_to_string(pid_file_path) else {
1570 return;
1571 };
1572 let Ok(pid) = source.trim().parse::<u32>() else {
1573 return;
1574 };
1575
1576 let _ = Command::new("kill")
1577 .arg("-TERM")
1578 .arg(format!("-{pid}"))
1579 .status();
1580 thread::sleep(Duration::from_millis(100));
1581}
1582
1583#[cfg(not(unix))]
1584fn terminate_stale_remote_module_service(_pid_file_path: &Path) {}
1585
1586async fn wait_for_remote_module_service_ready(
1587 client: &reqwest::Client,
1588 ready_url: &str,
1589 timeout: Duration,
1590) -> bool {
1591 let started = Instant::now();
1592 loop {
1593 if remote_service_ready(client, ready_url).await {
1594 return true;
1595 }
1596 if started.elapsed() >= timeout {
1597 return false;
1598 }
1599 tokio::time::sleep(Duration::from_millis(200)).await;
1600 }
1601}
1602
1603fn remote_module_service_state_path(
1604 services_state_dir: &Path,
1605 spec: &RemoteModuleServiceSpec,
1606 extension: &str,
1607) -> PathBuf {
1608 services_state_dir.join(format!(
1609 "remote-{}-{}.{}",
1610 remote_module_service_state_segment(&spec.module_name),
1611 remote_module_service_state_segment(&spec.service_name),
1612 extension
1613 ))
1614}
1615
1616fn remote_module_service_state_segment(value: &str) -> String {
1617 let mut segment = String::new();
1618 let mut previous_dash = false;
1619 for character in value.chars() {
1620 if character.is_ascii_alphanumeric() {
1621 segment.push(character.to_ascii_lowercase());
1622 previous_dash = false;
1623 } else if !segment.is_empty() && !previous_dash {
1624 segment.push('-');
1625 previous_dash = true;
1626 }
1627 }
1628 while segment.ends_with('-') {
1629 segment.pop();
1630 }
1631 if segment.is_empty() {
1632 "service".to_owned()
1633 } else {
1634 segment
1635 }
1636}
1637
1638fn terminate_remote_module_service(child: &mut Child) {
1639 if matches!(child.try_wait(), Ok(Some(_))) {
1640 return;
1641 }
1642
1643 #[cfg(unix)]
1644 {
1645 let process_group_id = child.id();
1646 let _ = Command::new("kill")
1647 .arg("-TERM")
1648 .arg(format!("-{process_group_id}"))
1649 .status();
1650 if wait_for_remote_module_service_exit(
1651 child,
1652 Duration::from_millis(REMOTE_SERVICE_TERMINATE_GRACE_MS),
1653 ) {
1654 return;
1655 }
1656 }
1657
1658 let _ = child.kill();
1659 let _ = child.wait();
1660}
1661
1662fn wait_for_remote_module_service_exit(child: &mut Child, timeout: Duration) -> bool {
1663 let started = Instant::now();
1664 loop {
1665 match child.try_wait() {
1666 Ok(Some(_)) => return true,
1667 Ok(None) => {}
1668 Err(_) => return true,
1669 }
1670 if started.elapsed() >= timeout {
1671 return false;
1672 }
1673 thread::sleep(Duration::from_millis(50));
1674 }
1675}
1676
1677#[cfg(unix)]
1678fn configure_remote_module_service_process(command: &mut Command) {
1679 use std::os::unix::process::CommandExt;
1680 command.process_group(0);
1681}
1682
1683#[cfg(not(unix))]
1684fn configure_remote_module_service_process(_command: &mut Command) {}
1685
1686fn shell_command(command: &str) -> Command {
1687 if cfg!(windows) {
1688 let mut process = Command::new("cmd");
1689 process.arg("/C").arg(command);
1690 process
1691 } else {
1692 let mut process = Command::new("sh");
1693 process.arg("-c").arg(command);
1694 process
1695 }
1696}
1697
1698fn current_timestamp() -> String {
1699 use platform_core::Clock;
1700 platform_core::SystemClock.now().to_rfc3339()
1701}
1702
1703fn duration_ms(started: Instant) -> u64 {
1704 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1705}
1706
1707#[must_use]
1709pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1710 let mut registry = FunctionRegistry::default();
1711 for module in modules {
1712 module.binding.register_functions(&mut registry);
1713 }
1714 registry
1715}
1716
1717pub async fn enqueue_lifecycle_activation_jobs(
1723 ctx: &AppContext,
1724 modules: &[Module],
1725 registry: &FunctionRegistry,
1726) -> platform_core::AppResult<Vec<String>> {
1727 validate_lifecycle_activation_jobs(modules, registry)?;
1728
1729 let client = RuntimeClient::new(ctx.db.clone());
1730 let mut run_ids = Vec::new();
1731
1732 for module in modules {
1733 let Some(lifecycle) = &module.manifest.lifecycle else {
1734 continue;
1735 };
1736
1737 for job in &lifecycle.activation_jobs {
1738 if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1739 continue;
1740 }
1741 if !module_declares_runtime_function(module, &job.function_name) {
1742 continue;
1743 }
1744
1745 let Some(definition) = registry.get(&job.function_name) else {
1746 continue;
1747 };
1748
1749 let enqueue_result = client
1750 .enqueue_function(EnqueueFunctionRequest {
1751 function_name: job.function_name.clone(),
1752 input_json: job.input.clone(),
1753 correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1754 actor: ActorContext::Service {
1755 service_id: "worker".to_owned(),
1756 scopes: vec!["runtime.functions.enqueue".to_owned()],
1757 },
1758 trace: TraceContext::default(),
1759 causation_id: Some(format!(
1760 "module_lifecycle:{}:{}",
1761 module.manifest.name, job.name
1762 )),
1763 max_attempts: Some(runtime_max_attempts_for_enqueue(
1764 definition.retry_policy.max_attempts,
1765 )),
1766 })
1767 .await;
1768
1769 match enqueue_result {
1770 Ok(run_id) => run_ids.push(run_id),
1771 Err(error) if job.required => return Err(error),
1772 Err(error) => warn_optional_lifecycle_enqueue_failure(
1773 &module.manifest.name,
1774 &job.name,
1775 &job.function_name,
1776 &error,
1777 ),
1778 }
1779 }
1780 }
1781
1782 Ok(run_ids)
1783}
1784
1785fn validate_lifecycle_activation_jobs(
1786 modules: &[Module],
1787 registry: &FunctionRegistry,
1788) -> platform_core::AppResult<()> {
1789 for module in modules {
1790 let Some(lifecycle) = &module.manifest.lifecycle else {
1791 continue;
1792 };
1793
1794 for check in &lifecycle.startup_checks {
1795 match &check.check {
1796 LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1797 if !module_declares_runtime_function(module, function_name) {
1798 let reason = format!(
1799 "startup check `{}` references function `{}` not declared by module `{}`",
1800 check.name, function_name, module.manifest.name
1801 );
1802 if !check.required {
1803 warn_optional_lifecycle_skip(
1804 &module.manifest.name,
1805 "startup_checks",
1806 &check.name,
1807 &reason,
1808 );
1809 continue;
1810 }
1811 return Err(lifecycle_validation_error(
1812 &module.manifest.name,
1813 "startup_checks",
1814 &check.name,
1815 format!("required {reason}"),
1816 ));
1817 }
1818 if registry.get(function_name).is_none() {
1819 let reason = format!(
1820 "startup check `{}` references missing function `{}`",
1821 check.name, function_name
1822 );
1823 if !check.required {
1824 warn_optional_lifecycle_skip(
1825 &module.manifest.name,
1826 "startup_checks",
1827 &check.name,
1828 &reason,
1829 );
1830 continue;
1831 }
1832 return Err(lifecycle_validation_error(
1833 &module.manifest.name,
1834 "startup_checks",
1835 &check.name,
1836 format!("required {reason}"),
1837 ));
1838 }
1839 }
1840 LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1841 if !module.manifest.capabilities.contains(capability) {
1842 let reason = format!(
1843 "startup check `{}` references missing capability `{}`",
1844 check.name, capability
1845 );
1846 if !check.required {
1847 warn_optional_lifecycle_skip(
1848 &module.manifest.name,
1849 "startup_checks",
1850 &check.name,
1851 &reason,
1852 );
1853 continue;
1854 }
1855 return Err(lifecycle_validation_error(
1856 &module.manifest.name,
1857 "startup_checks",
1858 &check.name,
1859 format!("required {reason}"),
1860 ));
1861 }
1862 }
1863 _ => {
1864 let reason = format!(
1865 "startup check `{}` uses an unsupported lifecycle check kind",
1866 check.name
1867 );
1868 if !check.required {
1869 warn_optional_lifecycle_skip(
1870 &module.manifest.name,
1871 "startup_checks",
1872 &check.name,
1873 &reason,
1874 );
1875 continue;
1876 }
1877 return Err(lifecycle_validation_error(
1878 &module.manifest.name,
1879 "startup_checks",
1880 &check.name,
1881 format!("required {reason}"),
1882 ));
1883 }
1884 }
1885 }
1886
1887 for job in &lifecycle.activation_jobs {
1888 if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1889 continue;
1890 }
1891
1892 if !module_declares_runtime_function(module, &job.function_name) {
1893 let reason = format!(
1894 "activation job `{}` references function `{}` not declared by module `{}`",
1895 job.name, job.function_name, module.manifest.name
1896 );
1897 if !job.required {
1898 warn_optional_lifecycle_skip(
1899 &module.manifest.name,
1900 "activation_jobs",
1901 &job.name,
1902 &reason,
1903 );
1904 continue;
1905 }
1906 return Err(lifecycle_validation_error(
1907 &module.manifest.name,
1908 "activation_jobs",
1909 &job.name,
1910 format!("required {reason}"),
1911 ));
1912 }
1913 if registry.get(&job.function_name).is_none() {
1914 let reason = format!(
1915 "activation job `{}` references missing function `{}`",
1916 job.name, job.function_name
1917 );
1918 if !job.required {
1919 warn_optional_lifecycle_skip(
1920 &module.manifest.name,
1921 "activation_jobs",
1922 &job.name,
1923 &reason,
1924 );
1925 continue;
1926 }
1927 return Err(lifecycle_validation_error(
1928 &module.manifest.name,
1929 "activation_jobs",
1930 &job.name,
1931 format!("required {reason}"),
1932 ));
1933 }
1934 }
1935 }
1936
1937 Ok(())
1938}
1939
1940fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1941 module.manifest.runtime.as_ref().is_some_and(|runtime| {
1942 runtime
1943 .functions
1944 .iter()
1945 .any(|function| function.name == function_name)
1946 })
1947}
1948
1949fn lifecycle_validation_error(
1950 module_name: &str,
1951 collection: &str,
1952 item_name: &str,
1953 reason: String,
1954) -> AppError {
1955 AppError::validation(
1956 "Module lifecycle declaration failed validation",
1957 vec![ErrorDetail {
1958 field: Some(format!(
1959 "module.{module_name}.lifecycle.{collection}.{item_name}"
1960 )),
1961 reason,
1962 }],
1963 )
1964}
1965
1966fn warn_optional_lifecycle_skip(
1967 module_name: &str,
1968 collection: &str,
1969 item_name: &str,
1970 reason: &str,
1971) {
1972 tracing::warn!(
1973 module_name = %module_name,
1974 lifecycle_collection = %collection,
1975 lifecycle_item = %item_name,
1976 reason = %reason,
1977 "optional module lifecycle declaration skipped"
1978 );
1979}
1980
1981fn warn_optional_lifecycle_enqueue_failure(
1982 module_name: &str,
1983 job_name: &str,
1984 function_name: &str,
1985 error: &AppError,
1986) {
1987 tracing::warn!(
1988 module_name = %module_name,
1989 lifecycle_collection = "activation_jobs",
1990 lifecycle_item = %job_name,
1991 function_name = %function_name,
1992 error_code = %error.code.as_str(),
1993 error_message = %error.public_message,
1994 "optional module lifecycle activation enqueue failed"
1995 );
1996}
1997
1998fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1999 i32::try_from(max_attempts).unwrap_or(i32::MAX)
2000}
2001
2002#[must_use]
2004pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
2005 event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
2006}
2007
2008#[must_use]
2011pub fn event_handlers_with_runtime_actions(
2012 ctx: &AppContext,
2013 modules: &[Module],
2014 function_registry: Arc<FunctionRegistry>,
2015) -> EventHandlerRegistry {
2016 let context = EventHandlerRegistrationContext::with_runtime(
2017 RuntimeClient::new(ctx.db.clone()),
2018 function_registry,
2019 );
2020 event_handlers_with_context(modules, &context)
2021}
2022
2023fn event_handlers_with_context(
2024 modules: &[Module],
2025 context: &EventHandlerRegistrationContext,
2026) -> EventHandlerRegistry {
2027 let mut registry = EventHandlerRegistry::new();
2028 for module in modules {
2029 module
2030 .binding
2031 .register_event_handlers(&mut registry, context);
2032 }
2033 registry
2034}
2035
2036pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
2044 merge_linked_http_for_profile(base, CompositionProfile::default())
2045}
2046
2047pub fn merge_linked_http_for_profile(
2048 base: ApiOpenApiRouter,
2049 profile: CompositionProfile,
2050) -> ApiOpenApiRouter {
2051 linked_http_modules_for_profile(profile)
2052 .into_iter()
2053 .filter_map(|module| module.linked_http)
2054 .fold(base, |router, contribution| (contribution.merge)(router))
2055}
2056
2057pub fn merge_linked_http_for_config(
2058 base: ApiOpenApiRouter,
2059 config: &platform_core::AppConfig,
2060) -> platform_core::AppResult<ApiOpenApiRouter> {
2061 Ok(linked_http_modules_for_config(config)?
2062 .into_iter()
2063 .filter_map(|module| module.linked_http)
2064 .fold(base, |router, contribution| (contribution.merge)(router)))
2065}
2066
2067pub fn merge_linked_http_for_context(
2068 base: ApiOpenApiRouter,
2069 ctx: &AppContext,
2070) -> platform_core::AppResult<ApiOpenApiRouter> {
2071 Ok(linked_http_modules_for_context(ctx)?
2072 .into_iter()
2073 .filter_map(|module| module.linked_http)
2074 .fold(base, |router, contribution| (contribution.merge)(router)))
2075}
2076
2077pub fn merge_linked_http_for_context_with_composition(
2078 base: ApiOpenApiRouter,
2079 ctx: &AppContext,
2080 composition: &HostComposition,
2081) -> platform_core::AppResult<ApiOpenApiRouter> {
2082 Ok(
2083 linked_http_modules_for_context_with_composition(ctx, composition)?
2084 .into_iter()
2085 .filter_map(|module| module.linked_http)
2086 .fold(base, |router, contribution| (contribution.merge)(router)),
2087 )
2088}
2089
2090#[must_use]
2093pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
2094 story_display_descriptors_for_profile(CompositionProfile::default())
2095}
2096
2097#[must_use]
2098pub fn story_display_descriptors_for_profile(
2099 profile: CompositionProfile,
2100) -> Vec<StoryDisplayDescriptor> {
2101 module_manifests_for_profile(profile)
2102 .into_iter()
2103 .flat_map(story_display_descriptors_from_manifest)
2104 .collect()
2105}
2106
2107pub fn story_display_descriptors_for_config(
2108 config: &platform_core::AppConfig,
2109) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2110 Ok(linked_module_entries_for_config(config)?
2111 .into_iter()
2112 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2113 .collect())
2114}
2115
2116pub fn story_display_descriptors_for_context(
2117 ctx: &AppContext,
2118) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2119 Ok(linked_module_entries_for_context(ctx)?
2120 .into_iter()
2121 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2122 .collect())
2123}
2124
2125pub fn install_default_story_display_catalog(ctx: &AppContext) -> platform_core::AppResult<()> {
2126 install_default_story_display_catalog_with_composition(ctx, &HostComposition::default())
2127}
2128
2129pub fn install_default_story_display_catalog_with_composition(
2130 ctx: &AppContext,
2131 composition: &HostComposition,
2132) -> platform_core::AppResult<()> {
2133 if !linked_module_enabled(ctx, story::module::MODULE_NAME) {
2134 story::backend::install_default_story_display(Vec::new());
2135 return Ok(());
2136 }
2137 let mut descriptors = story_display_descriptors_for_context(ctx)?;
2138 descriptors.extend(
2139 host_linked_modules_for_context(ctx, composition)
2140 .into_iter()
2141 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)())),
2142 );
2143 story::backend::install_default_story_display(descriptors);
2144 Ok(())
2145}
2146
2147pub fn install_story_display_catalog(metadata: &[AdminModuleMetadata]) {
2148 story::backend::install_story_display(
2149 metadata
2150 .iter()
2151 .flat_map(story_display_descriptors_from_metadata)
2152 .collect(),
2153 );
2154}
2155
2156fn story_display_descriptors_from_metadata(
2157 module: &AdminModuleMetadata,
2158) -> Vec<StoryDisplayDescriptor> {
2159 story_display_descriptors_from_manifest(
2160 ModuleManifest::builder(module.module_name.clone())
2161 .story_display(module.story_display.clone())
2162 .http_routes(module.http_routes.clone())
2163 .build(),
2164 )
2165}
2166
2167fn story_display_descriptors_from_manifest(
2168 manifest: ModuleManifest,
2169) -> Vec<StoryDisplayDescriptor> {
2170 let mut descriptors = manifest.story_display;
2171 let existing_http = descriptors
2172 .iter()
2173 .filter_map(|descriptor| match &descriptor.source {
2174 StoryDisplaySource::HttpRequest { method, path } => {
2175 Some((method.clone(), path.clone()))
2176 }
2177 StoryDisplaySource::ExecutionName { .. } => None,
2178 })
2179 .collect::<Vec<_>>();
2180
2181 descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
2182 let display_name = route.display_name?;
2183 let method = http_method_label(route.method)?;
2184 if existing_http
2185 .iter()
2186 .any(|(existing_method, existing_path)| {
2187 existing_method == method && existing_path == &route.path
2188 })
2189 {
2190 return None;
2191 }
2192 Some(StoryDisplayDescriptor {
2193 source: StoryDisplaySource::HttpRequest {
2194 method: method.to_owned(),
2195 path: route.path,
2196 },
2197 display_name,
2198 story_title: route.story_title,
2199 })
2200 }));
2201 descriptors
2202}
2203
2204fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
2205 Some(match method {
2206 ModuleHttpMethod::Get => "GET",
2207 ModuleHttpMethod::Post => "POST",
2208 ModuleHttpMethod::Put => "PUT",
2209 ModuleHttpMethod::Patch => "PATCH",
2210 ModuleHttpMethod::Delete => "DELETE",
2211 _ => return None,
2212 })
2213}
2214
2215pub fn runtime_config_descriptors(
2220 ctx: &AppContext,
2221) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2222 runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
2223}
2224
2225pub fn runtime_config_descriptors_with_composition(
2226 ctx: &AppContext,
2227 composition: &HostComposition,
2228) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2229 let profile = CompositionProfile::from_config(&ctx.config)?;
2230 let module_enabled_descriptors =
2231 linked_module_entries(profile)
2232 .iter()
2233 .map(|entry| RuntimeConfigDescriptor {
2234 key: module_enabled_config_key(entry.module_name),
2235 scope: RuntimeConfigScope::Shared,
2236 group: Some("modules"),
2237 section: None,
2238 order: 10,
2239 visible_when: None,
2240 generated: None,
2241 value_type: RuntimeConfigType::Bool,
2242 default: serde_json::json!(linked_module_enabled_from_config(
2243 &ctx.config,
2244 entry.module_name
2245 )),
2246 editable: true,
2247 restart_only: true,
2248 description: "Whether this linked module is loaded on service startup.",
2249 });
2250 let host_module_enabled_descriptors =
2251 composition
2252 .linked_modules()
2253 .iter()
2254 .map(|entry| RuntimeConfigDescriptor {
2255 key: module_enabled_config_key(entry.module_name),
2256 scope: RuntimeConfigScope::Shared,
2257 group: Some("modules"),
2258 section: None,
2259 order: 10,
2260 visible_when: None,
2261 generated: None,
2262 value_type: RuntimeConfigType::Bool,
2263 default: serde_json::json!(linked_module_enabled_from_config(
2264 &ctx.config,
2265 entry.module_name
2266 )),
2267 editable: true,
2268 restart_only: true,
2269 description: "Whether this host linked module is loaded on service startup.",
2270 });
2271 let remote_module_enabled_descriptors =
2272 ctx.config
2273 .module_sources
2274 .remote
2275 .iter()
2276 .map(|source| RuntimeConfigDescriptor {
2277 key: module_enabled_config_key(&source.name),
2278 scope: RuntimeConfigScope::Shared,
2279 group: Some("modules"),
2280 section: None,
2281 order: 10,
2282 visible_when: None,
2283 generated: None,
2284 value_type: RuntimeConfigType::Bool,
2285 default: serde_json::json!(remote_module_enabled_from_config(
2286 &ctx.config,
2287 &source.name
2288 )),
2289 editable: true,
2290 restart_only: true,
2291 description: "Whether this remote module is loaded on service startup.",
2292 });
2293 let module_descriptors = linked_module_entries(profile)
2294 .iter()
2295 .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2296 .map(|entry| (entry.load)(ctx))
2297 .chain(
2298 host_linked_modules_for_config(&ctx.config, composition)
2299 .into_iter()
2300 .map(|entry| load_host_linked_module(ctx, entry)),
2301 )
2302 .flat_map(|module| module.runtime_config.iter().cloned())
2303 .collect::<Vec<_>>();
2304 Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
2307 .iter()
2308 .cloned()
2309 .chain(module_enabled_descriptors)
2310 .chain(host_module_enabled_descriptors)
2311 .chain(remote_module_enabled_descriptors)
2312 .chain(module_descriptors)
2313 .collect())
2314}
2315
2316pub fn runtime_config_group_descriptors(
2318 ctx: &AppContext,
2319) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2320 runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
2321}
2322
2323pub fn runtime_config_group_descriptors_with_composition(
2324 ctx: &AppContext,
2325 composition: &HostComposition,
2326) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2327 let profile = CompositionProfile::from_config(&ctx.config)?;
2328 let module_groups = linked_module_entries(profile)
2329 .iter()
2330 .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2331 .map(|entry| (entry.load)(ctx))
2332 .chain(
2333 host_linked_modules_for_config(&ctx.config, composition)
2334 .into_iter()
2335 .map(|entry| load_host_linked_module(ctx, entry)),
2336 )
2337 .flat_map(|module| module.runtime_config_groups.iter().cloned())
2338 .collect::<Vec<_>>();
2339
2340 Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
2341 .chain(
2342 platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
2343 .iter()
2344 .cloned(),
2345 )
2346 .chain(module_groups)
2347 .collect())
2348}
2349
2350#[cfg(test)]
2351mod tests {
2352 use super::*;
2353 use async_trait::async_trait;
2354 use auth::models::AuthUserId;
2355 use auth::session_policy::{
2356 AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
2357 };
2358 use platform_core::{
2359 AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
2360 LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
2361 RemoteModuleSourceConfig, RuntimeConfigProvider, RuntimeConfigRegistry,
2362 RuntimeConfigSnapshot, ServiceConfig, TelemetryConfig, apply_migrations,
2363 };
2364 use platform_module::{
2365 ConsoleArea, LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration,
2366 LifecycleSurface, ModuleManifestLintSeverity, RuntimeFunctionDeclaration, RuntimeSurface,
2367 lint_module_manifest,
2368 };
2369 use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
2370 use platform_testing::{SequentialIdGenerator, TestDatabase};
2371 use serde_json::{Value, json};
2372 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
2373 use std::collections::BTreeMap;
2374 use std::sync::Arc;
2375 use std::time::Duration;
2376
2377 #[derive(Debug)]
2378 struct TestRuntimeConfigProvider {
2379 snapshot: Arc<RuntimeConfigSnapshot>,
2380 }
2381
2382 impl RuntimeConfigProvider for TestRuntimeConfigProvider {
2383 fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
2384 Arc::clone(&self.snapshot)
2385 }
2386 }
2387
2388 #[test]
2389 fn linked_module_entry_names_match_manifests() {
2390 for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
2391 for entry in linked_module_entries(profile) {
2392 assert_eq!(
2393 entry.module_name,
2394 (entry.manifest)().name,
2395 "linked module entry name must match ModuleManifest::name"
2396 );
2397 }
2398 }
2399 }
2400
2401 #[test]
2402 fn core_profile_excludes_demo_linked_modules() {
2403 let names = module_manifests_for_profile(CompositionProfile::Core)
2404 .into_iter()
2405 .map(|manifest| manifest.name)
2406 .collect::<Vec<_>>();
2407
2408 assert_eq!(names, vec!["platform-story"]);
2409 }
2410
2411 #[test]
2412 fn demo_profile_includes_fixture_linked_modules() {
2413 let names = module_manifests_for_profile(CompositionProfile::Demo)
2414 .into_iter()
2415 .map(|manifest| manifest.name)
2416 .collect::<Vec<_>>();
2417
2418 assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2419 }
2420
2421 #[test]
2422 fn http_route_metadata_contributes_story_display_descriptors() {
2423 let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
2424
2425 assert!(descriptors.iter().any(|descriptor| {
2426 matches!(
2427 &descriptor.source,
2428 StoryDisplaySource::HttpRequest { method, path }
2429 if method == "POST" && path == "/v1/auth/dev/sessions"
2430 ) && descriptor.display_name == "Create Development Session"
2431 }));
2432 }
2433
2434 #[test]
2435 fn core_profile_migrations_exclude_demo_module_migrations() {
2436 let names = migrations_for_profile(CompositionProfile::Core)
2437 .into_iter()
2438 .map(|migration| migration.name)
2439 .collect::<Vec<_>>();
2440
2441 assert!(names.iter().any(|name| name.starts_with("platform/")));
2442 assert!(names.iter().any(|name| name.starts_with("runtime/")));
2443 assert!(!names.iter().any(|name| name.starts_with("auth/")));
2444 assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2445 }
2446
2447 #[test]
2448 fn demo_profile_migrations_include_fixture_module_migrations() {
2449 let names = migrations_for_profile(CompositionProfile::Demo)
2450 .into_iter()
2451 .map(|migration| migration.name)
2452 .collect::<Vec<_>>();
2453
2454 assert!(
2455 names
2456 .iter()
2457 .any(|name| name == &"auth/0001_create_auth_schema")
2458 );
2459 assert!(
2460 names
2461 .iter()
2462 .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2463 );
2464 }
2465
2466 #[test]
2467 fn host_composition_migrations_include_enabled_host_linked_modules() {
2468 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2469 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2470
2471 let names = migrations_for_config_with_composition(&config, &composition)
2472 .expect("host composition migrations should load")
2473 .into_iter()
2474 .map(|migration| migration.name)
2475 .collect::<Vec<_>>();
2476
2477 assert!(names.iter().any(|name| name == &"billing/0001_init"));
2478 }
2479
2480 #[test]
2481 fn host_composition_can_install_auth_modules() {
2482 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2483 config.module_sources.linked_profile = "core".to_owned();
2484 let composition = HostComposition::new()
2485 .with_linked_module(auth_linked_module())
2486 .with_linked_module(auth_password_linked_module());
2487
2488 let names = migrations_for_config_with_composition(&config, &composition)
2489 .expect("host composition migrations should load")
2490 .into_iter()
2491 .map(|migration| migration.name)
2492 .collect::<Vec<_>>();
2493
2494 assert!(
2495 names
2496 .iter()
2497 .any(|name| name == &"auth/0001_create_auth_schema")
2498 );
2499 assert!(
2500 names
2501 .iter()
2502 .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2503 );
2504 }
2505
2506 #[tokio::test]
2507 async fn host_composition_runtime_config_includes_host_module_toggle() {
2508 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2509 .expect("lazy pool should build");
2510 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2511 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2512 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2513
2514 let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
2515 .expect("host composition descriptors should load")
2516 .into_iter()
2517 .map(|descriptor| descriptor.key)
2518 .collect::<Vec<_>>();
2519
2520 assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
2521 }
2522
2523 #[tokio::test]
2524 async fn host_composition_modules_include_manifest_only_modules() {
2525 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2526 .expect("lazy pool should build");
2527 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2528 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2529 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2530
2531 let names = modules_for_config_with_composition(&ctx, &composition)
2532 .expect("host composition modules should load")
2533 .into_iter()
2534 .map(|module| module.manifest.name)
2535 .collect::<Vec<_>>();
2536
2537 assert!(names.iter().any(|name| name == "billing"));
2538 }
2539
2540 #[tokio::test]
2541 async fn host_wiring_collects_auth_session_policy_contributions() {
2542 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2543 .expect("lazy pool should build");
2544 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2545 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2546 let composition = HostComposition::new().with_linked_module(
2547 test_host_linked_module()
2548 .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
2549 );
2550
2551 let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
2552 .expect("host wiring should compose");
2553 let now = ctx.clock.now();
2554 let decision = wiring
2555 .auth_session_policy()
2556 .policy()
2557 .before_session_create(&SessionCreateInput {
2558 user_id: AuthUserId("usr_wiring".to_owned()),
2559 session_id: "sess_wiring".to_owned(),
2560 proposed_device_id: Some("device_wiring".to_owned()),
2561 created_at: now,
2562 expires_at: now,
2563 })
2564 .await
2565 .expect("wired policy should allow session");
2566
2567 assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
2568 }
2569
2570 fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
2571 Arc::new(TestSessionPolicy)
2572 }
2573
2574 #[derive(Debug)]
2575 struct TestSessionPolicy;
2576
2577 #[async_trait]
2578 impl AuthSessionPolicy for TestSessionPolicy {
2579 async fn before_session_create(
2580 &self,
2581 input: &SessionCreateInput,
2582 ) -> platform_core::AppResult<SessionCreateDecision> {
2583 assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
2584 Ok(SessionCreateDecision {
2585 device_id: Some("device_from_wiring".to_owned()),
2586 })
2587 }
2588 }
2589
2590 #[test]
2591 fn demo_profile_includes_every_core_entry() {
2592 let demo_names = linked_module_entries(CompositionProfile::Demo)
2593 .iter()
2594 .map(|entry| entry.module_name)
2595 .collect::<Vec<_>>();
2596
2597 for core_entry in linked_module_entries(CompositionProfile::Core) {
2598 assert!(
2599 demo_names.contains(&core_entry.module_name),
2600 "demo profile should include core linked module `{}`",
2601 core_entry.module_name
2602 );
2603 }
2604 }
2605
2606 #[test]
2607 fn default_module_manifests_use_demo_profile() {
2608 let names = module_manifests()
2609 .into_iter()
2610 .map(|manifest| manifest.name)
2611 .collect::<Vec<_>>();
2612
2613 assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2614 }
2615
2616 #[test]
2617 fn linked_http_route_owners_are_profile_aware() {
2618 assert_eq!(
2619 linked_http_route_owners_for_profile(CompositionProfile::Core),
2620 vec![LinkedHttpRouteOwner {
2621 module_name: "platform-story".to_owned(),
2622 public_prefixes: &["/admin/runtime/stories"],
2623 }]
2624 );
2625 assert_eq!(
2626 linked_http_route_owners_for_profile(CompositionProfile::Demo),
2627 vec![
2628 LinkedHttpRouteOwner {
2629 module_name: "auth".to_owned(),
2630 public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
2631 },
2632 LinkedHttpRouteOwner {
2633 module_name: "auth-password".to_owned(),
2634 public_prefixes: &["/v1/auth/password/"],
2635 },
2636 LinkedHttpRouteOwner {
2637 module_name: "platform-story".to_owned(),
2638 public_prefixes: &["/admin/runtime/stories"],
2639 },
2640 ]
2641 );
2642 }
2643
2644 #[tokio::test]
2645 async fn modules_for_config_uses_core_linked_profile() {
2646 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2647 .expect("lazy pool should build");
2648 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2649 config.module_sources.linked_profile = "core".to_owned();
2650 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2651
2652 let names = modules_for_config(&ctx)
2653 .expect("core linked profile should parse")
2654 .into_iter()
2655 .map(|module| module.manifest.name)
2656 .collect::<Vec<_>>();
2657
2658 assert_eq!(names, vec!["platform-story"]);
2659 }
2660
2661 #[tokio::test]
2662 async fn auth_actor_resolver_is_profile_and_composition_aware() {
2663 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2664 .expect("lazy pool should build");
2665 let demo_ctx = AppContext::new(
2666 test_config_with_database_url("postgres://localhost/lenso_test"),
2667 db.clone(),
2668 Arc::new(LoggingEventPublisher),
2669 );
2670 assert!(
2671 auth_actor_resolver_for_context(&demo_ctx)
2672 .expect("demo profile")
2673 .is_some()
2674 );
2675
2676 let mut composition_config =
2677 test_config_with_database_url("postgres://localhost/lenso_test");
2678 composition_config.module_sources.linked_profile = "core".to_owned();
2679 let composition_ctx = AppContext::new(
2680 composition_config,
2681 db.clone(),
2682 Arc::new(LoggingEventPublisher),
2683 );
2684 let composition = HostComposition::new().with_linked_module(auth_linked_module());
2685 assert!(
2686 auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2687 .expect("auth composition")
2688 .is_some()
2689 );
2690
2691 let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2692 core_config.module_sources.linked_profile = "core".to_owned();
2693 let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2694 assert!(
2695 auth_actor_resolver_for_context(&core_ctx)
2696 .expect("core profile")
2697 .is_none()
2698 );
2699 }
2700
2701 #[tokio::test]
2702 async fn auth_actor_resolver_respects_disabled_auth_module() {
2703 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2704 .expect("lazy pool should build");
2705 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2706 config.modules.insert(
2707 auth::module::MODULE_NAME.to_owned(),
2708 ModuleConfig {
2709 enabled: Some(false),
2710 values: BTreeMap::new(),
2711 },
2712 );
2713 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2714
2715 assert!(
2716 auth_actor_resolver_for_context(&ctx)
2717 .expect("demo profile")
2718 .is_none()
2719 );
2720 }
2721
2722 #[tokio::test]
2723 async fn auth_password_requires_auth_module() {
2724 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2725 .expect("lazy pool should build");
2726 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2727 config.modules.insert(
2728 auth::module::MODULE_NAME.to_owned(),
2729 ModuleConfig {
2730 enabled: Some(false),
2731 values: BTreeMap::new(),
2732 },
2733 );
2734 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2735
2736 let names = modules_for_config(&ctx)
2737 .expect("demo profile")
2738 .into_iter()
2739 .map(|module| module.manifest.name)
2740 .collect::<Vec<_>>();
2741
2742 assert!(!names.iter().any(|name| name == "auth-password"));
2743 }
2744
2745 #[tokio::test]
2746 async fn auth_password_dependency_status_is_visible_in_metadata() {
2747 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2748 .expect("lazy pool should build");
2749 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2750 config.modules.insert(
2751 auth::module::MODULE_NAME.to_owned(),
2752 ModuleConfig {
2753 enabled: Some(false),
2754 values: BTreeMap::new(),
2755 },
2756 );
2757 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2758
2759 let metadata = load_admin_module_metadata(&ctx)
2760 .await
2761 .expect("module metadata should load");
2762 let auth_password = metadata
2763 .iter()
2764 .find(|module| module.module_name == "auth-password")
2765 .expect("dependency-disabled provider should remain visible in metadata");
2766
2767 assert_eq!(
2768 auth_password.dependencies,
2769 vec![auth::module::MODULE_NAME.to_owned()]
2770 );
2771 assert!(matches!(
2772 &auth_password.load_status,
2773 ModuleLoadStatus::Error { message }
2774 if message == "module dependency disabled: auth"
2775 ));
2776 }
2777
2778 #[tokio::test]
2779 async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2780 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2781 .expect("lazy pool should build");
2782 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2783 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2784 let registry =
2785 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2786 .expect("registry");
2787 let mut stored = BTreeMap::new();
2788 stored.insert(
2789 ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2790 json!("jwt"),
2791 );
2792 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
2793 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2794 snapshot: Arc::new(snapshot),
2795 }));
2796
2797 assert!(
2798 auth_actor_resolver_for_context(&ctx)
2799 .expect("JWT resolver should be skipped until jwt_secret is configured")
2800 .is_some()
2801 );
2802 }
2803
2804 #[tokio::test]
2805 async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
2806 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2807 .expect("lazy pool should build");
2808 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2809 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2810 let registry =
2811 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2812 .expect("registry");
2813 let mut stored = BTreeMap::new();
2814 stored.insert(
2815 ("*".to_owned(), "auth.session_cache".to_owned()),
2816 json!("redis"),
2817 );
2818 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
2819 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2820 snapshot: Arc::new(snapshot),
2821 }));
2822
2823 let error =
2824 auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
2825
2826 assert_eq!(error.code, ErrorCode::Validation);
2827 }
2828
2829 #[tokio::test]
2830 async fn modules_for_config_skips_disabled_linked_modules() {
2831 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2832 .expect("lazy pool should build");
2833 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2834 config.modules.insert(
2835 "auth-password".to_owned(),
2836 ModuleConfig {
2837 enabled: Some(false),
2838 values: BTreeMap::new(),
2839 },
2840 );
2841 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2842
2843 let names = modules_for_config(&ctx)
2844 .expect("demo linked profile should parse")
2845 .into_iter()
2846 .map(|module| module.manifest.name)
2847 .collect::<Vec<_>>();
2848
2849 assert_eq!(names, vec!["auth", "platform-story"]);
2850 }
2851
2852 #[tokio::test]
2853 async fn modules_for_config_uses_runtime_config_enabled_flag() {
2854 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2855 .expect("lazy pool should build");
2856 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2857 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2858 let registry =
2859 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2860 .expect("registry");
2861 let mut stored = BTreeMap::new();
2862 stored.insert(
2863 ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2864 json!(false),
2865 );
2866 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
2867 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2868 snapshot: Arc::new(snapshot),
2869 }));
2870
2871 let names = modules_for_config(&ctx)
2872 .expect("demo linked profile should parse")
2873 .into_iter()
2874 .map(|module| module.manifest.name)
2875 .collect::<Vec<_>>();
2876
2877 assert_eq!(names, vec!["auth", "platform-story"]);
2878 let linked_http_names = linked_http_modules_for_context(&ctx)
2879 .expect("linked HTTP modules should load")
2880 .into_iter()
2881 .map(|module| module.manifest.name)
2882 .collect::<Vec<_>>();
2883
2884 assert_eq!(linked_http_names, vec!["auth", "platform-story"]);
2885 }
2886
2887 #[tokio::test]
2888 async fn story_module_runtime_config_disables_backend_metadata() {
2889 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2890 .expect("lazy pool should build");
2891 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2892 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2893 let registry =
2894 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2895 .expect("registry");
2896 let mut stored = BTreeMap::new();
2897 stored.insert(
2898 ("*".to_owned(), "modules.platform-story.enabled".to_owned()),
2899 json!(false),
2900 );
2901 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
2902 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2903 snapshot: Arc::new(snapshot),
2904 }));
2905
2906 let linked_http_names = linked_http_modules_for_context(&ctx)
2907 .expect("linked HTTP modules should load")
2908 .into_iter()
2909 .map(|module| module.manifest.name)
2910 .collect::<Vec<_>>();
2911 assert_eq!(linked_http_names, vec!["auth", "auth-password"]);
2912
2913 let metadata = load_admin_module_metadata(&ctx)
2914 .await
2915 .expect("module metadata should load");
2916 let story = metadata
2917 .iter()
2918 .find(|module| module.module_name == "platform-story")
2919 .expect("disabled story module should remain visible in metadata");
2920
2921 assert!(matches!(
2922 &story.load_status,
2923 ModuleLoadStatus::Error { message }
2924 if message == "module disabled by configuration"
2925 ));
2926 assert_eq!(story.console.len(), 1);
2927 assert_eq!(story.http_routes.len(), story::module::http_routes().len());
2928 }
2929
2930 #[tokio::test]
2931 async fn runtime_config_descriptors_include_module_enabled_flags() {
2932 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2933 .expect("lazy pool should build");
2934 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2935 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2936
2937 let keys = runtime_config_descriptors(&ctx)
2938 .expect("descriptors should load")
2939 .into_iter()
2940 .map(|descriptor| {
2941 (
2942 descriptor.key,
2943 descriptor.group,
2944 descriptor.restart_only,
2945 descriptor.default,
2946 )
2947 })
2948 .collect::<Vec<_>>();
2949
2950 assert!(keys.iter().any(|(key, group, restart_only, default)| {
2951 key == "modules.auth.enabled"
2952 && *group == Some("modules")
2953 && *restart_only
2954 && default == &json!(true)
2955 }));
2956 assert!(keys.iter().any(|(key, group, restart_only, default)| {
2957 key == "modules.auth-password.enabled"
2958 && *group == Some("modules")
2959 && *restart_only
2960 && default == &json!(true)
2961 }));
2962 assert!(keys.iter().any(|(key, group, restart_only, default)| {
2963 key == "modules.platform-story.enabled"
2964 && *group == Some("modules")
2965 && *restart_only
2966 && default == &json!(true)
2967 }));
2968 }
2969
2970 #[tokio::test]
2971 async fn runtime_config_groups_include_module_owned_groups() {
2972 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2973 .expect("lazy pool should build");
2974 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2975 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2976
2977 let groups = runtime_config_group_descriptors(&ctx)
2978 .expect("groups should load")
2979 .into_iter()
2980 .map(|group| (group.id, group.label))
2981 .collect::<Vec<_>>();
2982
2983 assert!(groups.contains(&("modules", "Modules")));
2984 assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2985 assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2986 assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2987 }
2988
2989 #[tokio::test]
2990 async fn runtime_config_descriptors_include_remote_module_enabled_flags() {
2991 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2992 .expect("lazy pool should build");
2993 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2994 config.module_sources.remote.push(RemoteModuleSourceConfig {
2995 name: "remote-crm".to_owned(),
2996 base_url: "http://127.0.0.1:65535".to_owned(),
2997 auth_token_env: None,
2998 timeout_ms: 1,
2999 });
3000 config.modules.insert(
3001 "remote-crm".to_owned(),
3002 ModuleConfig {
3003 enabled: Some(false),
3004 values: BTreeMap::new(),
3005 },
3006 );
3007 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3008
3009 let keys = runtime_config_descriptors(&ctx)
3010 .expect("descriptors should load")
3011 .into_iter()
3012 .map(|descriptor| (descriptor.key, descriptor.restart_only, descriptor.default))
3013 .collect::<Vec<_>>();
3014
3015 assert!(keys.iter().any(|(key, restart_only, default)| {
3016 key == "modules.remote-crm.enabled" && *restart_only && default == &json!(false)
3017 }));
3018 }
3019
3020 #[tokio::test]
3021 async fn load_modules_skips_runtime_disabled_remote_modules() {
3022 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3023 .expect("lazy pool should build");
3024 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3025 config.module_sources.remote.push(RemoteModuleSourceConfig {
3026 name: "remote-crm".to_owned(),
3027 base_url: "http://127.0.0.1:65535".to_owned(),
3028 auth_token_env: None,
3029 timeout_ms: 1,
3030 });
3031 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3032 let registry =
3033 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3034 .expect("registry");
3035 let mut stored = BTreeMap::new();
3036 stored.insert(
3037 ("*".to_owned(), "modules.remote-crm.enabled".to_owned()),
3038 json!(false),
3039 );
3040 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3041 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3042 snapshot: Arc::new(snapshot),
3043 }));
3044
3045 let names = load_modules(&ctx)
3046 .await
3047 .expect("disabled remote should not be loaded")
3048 .into_iter()
3049 .map(|module| module.manifest.name)
3050 .collect::<Vec<_>>();
3051
3052 assert!(!names.iter().any(|name| name == "remote-crm"));
3053 }
3054
3055 #[tokio::test]
3056 async fn module_metadata_reports_disabled_remote_modules() {
3057 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3058 .expect("lazy pool should build");
3059 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3060 config.module_sources.remote.push(RemoteModuleSourceConfig {
3061 name: "remote-grpc-crm".to_owned(),
3062 base_url: "grpc://127.0.0.1:65535".to_owned(),
3063 auth_token_env: None,
3064 timeout_ms: 1,
3065 });
3066 config.modules.insert(
3067 "remote-grpc-crm".to_owned(),
3068 ModuleConfig {
3069 enabled: Some(false),
3070 values: BTreeMap::new(),
3071 },
3072 );
3073 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3074
3075 let metadata = load_admin_module_metadata(&ctx)
3076 .await
3077 .expect("module metadata should load");
3078 let remote = metadata
3079 .iter()
3080 .find(|module| module.module_name == "remote-grpc-crm")
3081 .expect("disabled remote module should remain visible in metadata");
3082
3083 assert_eq!(remote.source, ModuleSource::Remote);
3084 assert!(matches!(
3085 &remote.load_status,
3086 ModuleLoadStatus::Error { message }
3087 if message == "module disabled by configuration"
3088 ));
3089 assert!(matches!(
3090 &remote.source_diagnostics,
3091 Some(AdminModuleSourceDiagnostics::Remote(diagnostics))
3092 if diagnostics.transport == "grpc"
3093 && diagnostics.base_url == "http://127.0.0.1:65535"
3094 ));
3095 }
3096
3097 #[test]
3098 fn migrations_for_config_skip_disabled_linked_module_migrations() {
3099 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3100 config.modules.insert(
3101 "auth-password".to_owned(),
3102 ModuleConfig {
3103 enabled: Some(false),
3104 values: BTreeMap::new(),
3105 },
3106 );
3107
3108 let names = migrations_for_config(&config)
3109 .expect("demo linked profile should parse")
3110 .into_iter()
3111 .map(|migration| migration.name)
3112 .collect::<Vec<_>>();
3113
3114 assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
3115 assert!(
3116 names
3117 .iter()
3118 .any(|name| name == &"auth/0001_create_auth_schema")
3119 );
3120 }
3121
3122 #[test]
3123 fn linked_http_modules_for_config_skip_disabled_linked_routes() {
3124 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3125 config.modules.insert(
3126 "auth-password".to_owned(),
3127 ModuleConfig {
3128 enabled: Some(false),
3129 values: BTreeMap::new(),
3130 },
3131 );
3132
3133 let names = linked_http_modules_for_config(&config)
3134 .expect("demo linked profile should parse")
3135 .into_iter()
3136 .map(|module| module.manifest.name)
3137 .collect::<Vec<_>>();
3138
3139 assert_eq!(names, vec!["auth", "platform-story"]);
3140 }
3141
3142 #[test]
3143 fn linked_http_modules_for_config_skip_disabled_story_routes() {
3144 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3145 config.modules.insert(
3146 "platform-story".to_owned(),
3147 ModuleConfig {
3148 enabled: Some(false),
3149 values: BTreeMap::new(),
3150 },
3151 );
3152
3153 let names = linked_http_modules_for_config(&config)
3154 .expect("demo linked profile should parse")
3155 .into_iter()
3156 .map(|module| module.manifest.name)
3157 .collect::<Vec<_>>();
3158
3159 assert_eq!(names, vec!["auth", "auth-password"]);
3160 }
3161
3162 #[tokio::test]
3163 async fn disabled_story_module_omits_default_story_display_catalog() {
3164 story::backend::reset_catalogs_for_test();
3165 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3166 config.modules.insert(
3167 "platform-story".to_owned(),
3168 ModuleConfig {
3169 enabled: Some(false),
3170 values: BTreeMap::new(),
3171 },
3172 );
3173 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3174 .expect("lazy pool should build");
3175 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3176
3177 install_default_story_display_catalog(&ctx)
3178 .expect("story display catalog installation should succeed");
3179
3180 assert!(story::backend::story_display_catalog_snapshot().is_empty());
3181 }
3182
3183 #[tokio::test]
3184 async fn module_metadata_reports_disabled_linked_modules() {
3185 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3186 .expect("lazy pool should build");
3187 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3188 config.modules.insert(
3189 "auth-password".to_owned(),
3190 ModuleConfig {
3191 enabled: Some(false),
3192 values: BTreeMap::new(),
3193 },
3194 );
3195 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3196
3197 let metadata = load_admin_module_metadata(&ctx)
3198 .await
3199 .expect("module metadata should load");
3200 let auth_password = metadata
3201 .iter()
3202 .find(|module| module.module_name == "auth-password")
3203 .expect("disabled module should remain visible in metadata");
3204
3205 assert!(matches!(
3206 &auth_password.load_status,
3207 ModuleLoadStatus::Error { message }
3208 if message == "module disabled by configuration"
3209 ));
3210 }
3211
3212 #[test]
3213 fn composition_profile_rejects_unknown_values() {
3214 let error = CompositionProfile::parse("fixture")
3215 .expect_err("fixture is not a supported linked module profile");
3216
3217 assert_eq!(error.code, ErrorCode::Validation);
3218 assert!(
3219 error
3220 .details
3221 .iter()
3222 .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
3223 );
3224 }
3225
3226 #[test]
3227 fn linked_http_route_owners_are_projected_from_modules() {
3228 assert_eq!(
3229 linked_http_route_owners(),
3230 vec![
3231 LinkedHttpRouteOwner {
3232 module_name: "auth".to_owned(),
3233 public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
3234 },
3235 LinkedHttpRouteOwner {
3236 module_name: "auth-password".to_owned(),
3237 public_prefixes: &["/v1/auth/password/"],
3238 },
3239 LinkedHttpRouteOwner {
3240 module_name: "platform-story".to_owned(),
3241 public_prefixes: &["/admin/runtime/stories"],
3242 },
3243 ]
3244 );
3245 }
3246
3247 #[test]
3248 fn linked_http_bindings_are_declared_in_manifests() {
3249 for module in linked_http_modules() {
3250 let http = module
3251 .linked_http
3252 .expect("linked HTTP module should carry HTTP contribution");
3253 assert!(
3254 !module.manifest.http_routes.is_empty(),
3255 "linked HTTP module `{}` must declare ModuleManifest::http_routes",
3256 module.manifest.name
3257 );
3258 for route in &module.manifest.http_routes {
3259 assert!(
3260 http.public_prefixes
3261 .iter()
3262 .any(|prefix| route.path.starts_with(prefix)),
3263 "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
3264 module.manifest.name,
3265 route.path
3266 );
3267 }
3268 }
3269 }
3270
3271 #[test]
3272 fn linked_http_modules_are_registered_modules() {
3273 let manifests = module_manifests();
3274
3275 for module in linked_http_modules() {
3276 let registered_manifest = manifests
3277 .iter()
3278 .find(|manifest| manifest.name == module.manifest.name)
3279 .unwrap_or_else(|| {
3280 panic!(
3281 "linked HTTP module `{}` is missing from module_manifests",
3282 module.manifest.name
3283 )
3284 });
3285 assert_eq!(
3286 registered_manifest, &module.manifest,
3287 "linked HTTP module `{}` must use the registered ModuleManifest",
3288 module.manifest.name
3289 );
3290 }
3291 }
3292
3293 #[test]
3294 fn linked_http_routes_include_story_module_routes() {
3295 let document = merge_linked_http(platform_http::OpenApiRouter::new()).to_openapi();
3296 let value = serde_json::to_value(document).expect("OpenAPI document should serialize");
3297 let paths = value["paths"].as_object().expect("OpenAPI paths object");
3298
3299 assert!(paths.contains_key("/admin/runtime/stories"));
3300 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}"));
3301 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/heatmap"));
3302 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/technical-operations"));
3303 }
3304
3305 #[test]
3306 fn platform_story_manifest_declares_story_console_surface() {
3307 let manifest = module_manifests()
3308 .into_iter()
3309 .find(|manifest| manifest.name == "platform-story")
3310 .expect("platform-story manifest should be registered");
3311 let console_surface_contract: Value = serde_json::from_str(include_str!(
3312 "../../../modules/story/console/console-surface.json"
3313 ))
3314 .expect("story console surface contract should be valid json");
3315
3316 assert_eq!(manifest.admin, None);
3317 assert_eq!(manifest.console.len(), 1);
3318 let surface = &manifest.console[0];
3319 let surface_json =
3320 serde_json::to_value(surface).expect("platform-story console surface should serialize");
3321
3322 assert_eq!(
3323 manifest.capabilities,
3324 required_capabilities_from_contract(&console_surface_contract)
3325 );
3326 assert_eq!(manifest.name, console_surface_contract["id"]);
3327 assert_eq!(surface.name, console_surface_contract["surfaceName"]);
3328 assert_eq!(surface.label, console_surface_contract["label"]);
3329 assert_eq!(surface.area, ConsoleArea::Runtime);
3330 assert_eq!(surface_json["area"], console_surface_contract["area"]);
3331 assert_eq!(surface.route, console_surface_contract["route"]);
3332 assert_eq!(
3333 surface.package.name,
3334 console_surface_contract["packageName"]
3335 );
3336 assert_eq!(
3337 surface.package.export,
3338 console_surface_contract["exportName"]
3339 );
3340 assert_eq!(surface_json["icon"], console_surface_contract["icon"]);
3341 assert_eq!(surface.navigation, None);
3342 assert!(console_surface_contract.get("navigation").is_none());
3343 assert_eq!(
3344 surface.required_capabilities,
3345 required_capabilities_from_contract(&console_surface_contract)
3346 );
3347
3348 let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
3349 assert!(
3350 lints
3351 .iter()
3352 .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
3353 "platform-story manifest should not have warning/error lints: {lints:?}"
3354 );
3355 }
3356
3357 fn required_capabilities_from_contract(contract: &Value) -> Vec<String> {
3358 contract["requiredCapabilities"]
3359 .as_array()
3360 .expect("requiredCapabilities should be an array")
3361 .iter()
3362 .map(|capability| {
3363 capability
3364 .as_str()
3365 .expect("requiredCapabilities should contain strings")
3366 .to_owned()
3367 })
3368 .collect()
3369 }
3370
3371 #[tokio::test]
3372 async fn lifecycle_activation_enqueue_creates_function_run() {
3373 let Some(db) = TestDatabase::create().await else {
3374 return;
3375 };
3376 apply_runtime_stack_migrations(&db).await;
3377
3378 let mut ctx = AppContext::new(
3379 test_config(&db),
3380 db.pool.clone(),
3381 Arc::new(LoggingEventPublisher),
3382 );
3383 ctx.ids = Arc::new(SequentialIdGenerator::default());
3384 let modules = vec![
3385 test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
3386 .into(),
3387 ];
3388 let registry = registry_with_lifecycle_function(7);
3389
3390 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
3391 .await
3392 .expect("lifecycle activation job should enqueue");
3393
3394 assert_eq!(run_ids.len(), 1);
3395 let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
3396 r#"
3397 select function_name, input_json, max_attempts, correlation_id, actor
3398 from runtime.function_runs
3399 where id = $1
3400 "#,
3401 )
3402 .bind(&run_ids[0])
3403 .fetch_one(&db.pool)
3404 .await
3405 .expect("function run should exist");
3406
3407 assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
3408 assert_eq!(row.1["warm"], "cache");
3409 assert_eq!(
3410 row.1["_lenso_runtime"]["correlation_id"],
3411 "corr_lifecycle_1"
3412 );
3413 assert_eq!(
3414 row.1["_lenso_runtime"]["causation_id"],
3415 "module_lifecycle:test-module:warm cache"
3416 );
3417 assert_eq!(row.2, 7);
3418 assert_eq!(row.3, "corr_lifecycle_1");
3419 assert_eq!(row.4["kind"], "service");
3420 assert_eq!(row.4["service_id"], "worker");
3421 assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
3422
3423 db.cleanup().await;
3424 }
3425
3426 #[test]
3427 fn lifecycle_activation_validation_rejects_required_missing_function() {
3428 let modules =
3429 vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
3430 let registry = FunctionRegistry::default();
3431
3432 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
3433 .expect_err("required missing activation function should fail validation");
3434
3435 assert_eq!(error.code, ErrorCode::Validation);
3436 assert_eq!(
3437 error.details[0].field.as_deref(),
3438 Some("module.test-module.lifecycle.activation_jobs.warm cache")
3439 );
3440 assert!(
3441 error.details[0].reason.contains("missing function"),
3442 "validation detail should name the missing registry function"
3443 );
3444 }
3445
3446 #[test]
3447 fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
3448 let modules = vec![test_lifecycle_module_with_lifecycle(
3449 LifecycleSurface {
3450 startup_checks: vec![LifecycleStartupCheckDeclaration {
3451 name: "function registered".to_owned(),
3452 required: true,
3453 check: LifecycleStartupCheckKind::FunctionRegistered {
3454 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3455 },
3456 }],
3457 activation_jobs: Vec::new(),
3458 },
3459 true,
3460 Vec::new(),
3461 )];
3462 let registry = FunctionRegistry::default();
3463
3464 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
3465 .expect_err("required startup check should fail when function is missing");
3466
3467 assert_eq!(error.code, ErrorCode::Validation);
3468 assert_eq!(
3469 error.details[0].field.as_deref(),
3470 Some("module.test-module.lifecycle.startup_checks.function registered")
3471 );
3472 assert!(
3473 error.details[0].reason.contains("missing function"),
3474 "validation detail should name the missing registry function"
3475 );
3476 }
3477
3478 #[test]
3479 fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
3480 let modules = vec![test_lifecycle_module_with_lifecycle(
3481 LifecycleSurface {
3482 startup_checks: vec![LifecycleStartupCheckDeclaration {
3483 name: "function registered".to_owned(),
3484 required: true,
3485 check: LifecycleStartupCheckKind::FunctionRegistered {
3486 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3487 },
3488 }],
3489 activation_jobs: Vec::new(),
3490 },
3491 false,
3492 Vec::new(),
3493 )];
3494 let registry = registry_with_lifecycle_function(3);
3495
3496 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
3497 .expect_err("required startup check should fail when manifest does not declare it");
3498
3499 assert_eq!(error.code, ErrorCode::Validation);
3500 assert_eq!(
3501 error.details[0].field.as_deref(),
3502 Some("module.test-module.lifecycle.startup_checks.function registered")
3503 );
3504 assert!(
3505 error.details[0].reason.contains("not declared"),
3506 "validation detail should name the missing module runtime declaration"
3507 );
3508 }
3509
3510 #[test]
3511 fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
3512 let modules = vec![test_lifecycle_module_with_lifecycle(
3513 LifecycleSurface {
3514 startup_checks: vec![LifecycleStartupCheckDeclaration {
3515 name: "capability declared".to_owned(),
3516 required: true,
3517 check: LifecycleStartupCheckKind::CapabilityDeclared {
3518 capability: "test.cache.warm".to_owned(),
3519 },
3520 }],
3521 activation_jobs: Vec::new(),
3522 },
3523 false,
3524 Vec::new(),
3525 )];
3526 let registry = FunctionRegistry::default();
3527
3528 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
3529 .expect_err("required startup check should fail when capability is missing");
3530
3531 assert_eq!(error.code, ErrorCode::Validation);
3532 assert_eq!(
3533 error.details[0].field.as_deref(),
3534 Some("module.test-module.lifecycle.startup_checks.capability declared")
3535 );
3536 assert!(
3537 error.details[0].reason.contains("missing capability"),
3538 "validation detail should name the missing capability"
3539 );
3540 }
3541
3542 #[test]
3543 fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
3544 let modules = vec![test_lifecycle_module_with_lifecycle(
3545 LifecycleSurface {
3546 startup_checks: vec![
3547 LifecycleStartupCheckDeclaration {
3548 name: "optional function".to_owned(),
3549 required: false,
3550 check: LifecycleStartupCheckKind::FunctionRegistered {
3551 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3552 },
3553 },
3554 LifecycleStartupCheckDeclaration {
3555 name: "optional capability".to_owned(),
3556 required: false,
3557 check: LifecycleStartupCheckKind::CapabilityDeclared {
3558 capability: "test.cache.warm".to_owned(),
3559 },
3560 },
3561 ],
3562 activation_jobs: Vec::new(),
3563 },
3564 false,
3565 Vec::new(),
3566 )];
3567 let registry = FunctionRegistry::default();
3568
3569 validate_lifecycle_activation_jobs(&modules, ®istry)
3570 .expect("optional startup checks should not fail validation");
3571 }
3572
3573 #[test]
3574 fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
3575 let modules = vec![
3576 test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
3577 .without_runtime_declaration()
3578 .into(),
3579 ];
3580 let registry = registry_with_lifecycle_function(3);
3581
3582 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
3583 .expect_err("required activation job should fail when manifest does not declare it");
3584
3585 assert_eq!(error.code, ErrorCode::Validation);
3586 assert_eq!(
3587 error.details[0].field.as_deref(),
3588 Some("module.test-module.lifecycle.activation_jobs.warm cache")
3589 );
3590 assert!(
3591 error.details[0].reason.contains("not declared"),
3592 "validation detail should name the missing module runtime declaration"
3593 );
3594 }
3595
3596 #[tokio::test]
3597 async fn optional_missing_lifecycle_activation_is_skipped() {
3598 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3599 .expect("lazy pool should build");
3600 let ctx = AppContext::new(
3601 test_config_with_database_url("postgres://localhost/lenso_test"),
3602 db,
3603 Arc::new(LoggingEventPublisher),
3604 );
3605 let modules =
3606 vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3607 let registry = FunctionRegistry::default();
3608
3609 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
3610 .await
3611 .expect("optional missing activation function should be skipped");
3612
3613 assert!(run_ids.is_empty());
3614 }
3615
3616 #[tokio::test]
3617 async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
3618 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3619 .expect("lazy pool should build");
3620 let ctx = AppContext::new(
3621 test_config_with_database_url("postgres://localhost/lenso_test"),
3622 db,
3623 Arc::new(LoggingEventPublisher),
3624 );
3625 let modules = vec![
3626 test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
3627 .without_runtime_declaration()
3628 .into(),
3629 ];
3630 let registry = registry_with_lifecycle_function(3);
3631
3632 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
3633 .await
3634 .expect("optional undeclared activation function should be skipped");
3635
3636 assert!(run_ids.is_empty());
3637 }
3638
3639 #[tokio::test]
3640 async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
3641 let db = PgPoolOptions::new()
3642 .max_connections(1)
3643 .acquire_timeout(Duration::from_millis(50))
3644 .connect_lazy_with(
3645 PgConnectOptions::new()
3646 .host("127.0.0.1")
3647 .port(1)
3648 .username("postgres")
3649 .database("lenso_test"),
3650 );
3651 let ctx = AppContext::new(
3652 test_config_with_database_url("postgres://localhost:1/lenso_test"),
3653 db,
3654 Arc::new(LoggingEventPublisher),
3655 );
3656 let modules =
3657 vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3658 let registry = registry_with_lifecycle_function(3);
3659
3660 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
3661 .await
3662 .expect("optional enqueue failure should be skipped");
3663
3664 assert!(run_ids.is_empty());
3665 }
3666
3667 #[test]
3668 fn lifecycle_activation_max_attempts_conversion_saturates() {
3669 assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3670 assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3671 }
3672
3673 const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3674
3675 #[derive(Debug)]
3676 struct NoopFunctionHandler;
3677
3678 #[async_trait]
3679 impl FunctionHandler for NoopFunctionHandler {
3680 async fn call(
3681 &self,
3682 _ctx: ExecutionContext,
3683 _input: Value,
3684 ) -> platform_core::AppResult<Value> {
3685 Ok(Value::Null)
3686 }
3687 }
3688
3689 fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3690 LifecycleActivationJobDeclaration {
3691 name: "warm cache".to_owned(),
3692 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3693 run_policy: LifecycleActivationRunPolicy::EveryStartup,
3694 input,
3695 required,
3696 }
3697 }
3698
3699 struct TestLifecycleModuleBuilder {
3700 lifecycle: LifecycleSurface,
3701 declare_runtime_function: bool,
3702 capabilities: Vec<String>,
3703 }
3704
3705 impl TestLifecycleModuleBuilder {
3706 fn without_runtime_declaration(mut self) -> Self {
3707 self.declare_runtime_function = false;
3708 self
3709 }
3710 }
3711
3712 impl From<TestLifecycleModuleBuilder> for Module {
3713 fn from(builder: TestLifecycleModuleBuilder) -> Self {
3714 let mut manifest = ModuleManifest::builder("test-module").lifecycle(builder.lifecycle);
3715 if builder.declare_runtime_function {
3716 manifest = manifest.runtime(RuntimeSurface {
3717 functions: vec![RuntimeFunctionDeclaration {
3718 name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3719 version: 1,
3720 queue: "test".to_owned(),
3721 input_schema: None,
3722 retry_policy: None,
3723 }],
3724 });
3725 }
3726 if !builder.capabilities.is_empty() {
3727 manifest = manifest.capabilities(builder.capabilities);
3728 }
3729 Module::linked(manifest.build(), LinkedBinding::builder().build())
3730 }
3731 }
3732
3733 fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3734 TestLifecycleModuleBuilder {
3735 lifecycle: LifecycleSurface {
3736 startup_checks: Vec::new(),
3737 activation_jobs: vec![job],
3738 },
3739 declare_runtime_function: true,
3740 capabilities: Vec::new(),
3741 }
3742 }
3743
3744 fn test_lifecycle_module_with_lifecycle(
3745 lifecycle: LifecycleSurface,
3746 declare_runtime_function: bool,
3747 capabilities: Vec<String>,
3748 ) -> Module {
3749 TestLifecycleModuleBuilder {
3750 lifecycle,
3751 declare_runtime_function,
3752 capabilities,
3753 }
3754 .into()
3755 }
3756
3757 fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3758 let mut registry = FunctionRegistry::default();
3759 registry.register(FunctionDefinition {
3760 name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3761 version: 1,
3762 queue: "test".to_owned(),
3763 retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3764 handler: Arc::new(NoopFunctionHandler),
3765 });
3766 registry
3767 }
3768
3769 #[test]
3770 fn remote_module_service_specs_parse() {
3771 let specs = parse_remote_module_service_specs(&serde_json::json!({
3772 "modules": [
3773 {
3774 "moduleName": "crm",
3775 "services": [
3776 {
3777 "name": "crm-api",
3778 "command": "pnpm dev",
3779 "cwd": "../crm",
3780 "readyUrl": "http://127.0.0.1:4100/lenso/module/v1/manifest",
3781 "readyTimeoutMs": 12000,
3782 "autoStart": true
3783 }
3784 ]
3785 }
3786 ],
3787 "version": 1
3788 }))
3789 .expect("service specs parse");
3790
3791 assert_eq!(specs.len(), 1);
3792 assert_eq!(specs[0].module_name, "crm");
3793 assert_eq!(specs[0].service_name, "crm-api");
3794 assert_eq!(specs[0].ready_timeout_ms, 12000);
3795 }
3796
3797 #[test]
3798 fn remote_module_service_state_path_sanitizes_names() {
3799 let spec = RemoteModuleServiceSpec {
3800 module_name: "CRM Module".to_owned(),
3801 service_name: "API Worker!".to_owned(),
3802 command: "pnpm dev".to_owned(),
3803 cwd: None,
3804 ready_url: "http://127.0.0.1:4100/lenso/module/v1/manifest".to_owned(),
3805 ready_timeout_ms: 12000,
3806 auto_start: true,
3807 };
3808
3809 let path = remote_module_service_state_path(Path::new(".lenso"), &spec, "lock");
3810
3811 assert_eq!(
3812 path,
3813 PathBuf::from(".lenso/remote-crm-module-api-worker.lock")
3814 );
3815 }
3816
3817 #[test]
3818 fn remote_module_service_lock_is_exclusive_and_released() {
3819 let unique = std::time::SystemTime::now()
3820 .duration_since(std::time::UNIX_EPOCH)
3821 .expect("system time should be after Unix epoch")
3822 .as_nanos();
3823 let dir = std::env::temp_dir().join(format!(
3824 "lenso-bootstrap-service-lock-{}-{unique}",
3825 std::process::id()
3826 ));
3827 let lock_file_path = dir.join("service.lock");
3828 let pid_file_path = dir.join("service.pid");
3829
3830 let _ = std::fs::remove_dir_all(&dir);
3831 create_remote_module_service_lock(&lock_file_path)
3832 .expect("first lock claim should create the lock");
3833 let second_claim = create_remote_module_service_lock(&lock_file_path)
3834 .expect_err("second lock claim should fail while the file exists");
3835 assert_eq!(second_claim.kind(), std::io::ErrorKind::AlreadyExists);
3836 std::fs::write(&pid_file_path, "123\n").expect("pid file should write");
3837
3838 release_remote_module_service_state(&lock_file_path, &pid_file_path);
3839
3840 assert!(!lock_file_path.exists());
3841 assert!(!pid_file_path.exists());
3842 let _ = std::fs::remove_dir_all(&dir);
3843 }
3844
3845 const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3846 name: "billing/0001_init",
3847 sql: "select 1;",
3848 }];
3849
3850 fn test_host_manifest() -> ModuleManifest {
3851 ModuleManifest::builder("billing").build()
3852 }
3853
3854 fn test_host_linked_module() -> HostLinkedModule {
3855 HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3856 }
3857
3858 fn test_config(db: &TestDatabase) -> AppConfig {
3859 test_config_with_database_url(db.url.clone())
3860 }
3861
3862 fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3863 AppConfig {
3864 service: ServiceConfig::default(),
3865 database: DatabaseConfig {
3866 url: database_url.into(),
3867 max_connections: 5,
3868 },
3869 redis: RedisConfig::default(),
3870 http: HttpConfig::default(),
3871 telemetry: TelemetryConfig::default(),
3872 auth: AuthConfig::default(),
3873 console: Default::default(),
3874 module_sources: ModuleSourcesConfig::default(),
3875 modules: BTreeMap::new(),
3876 }
3877 }
3878
3879 async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3880 let migrations = PLATFORM_MIGRATIONS
3881 .iter()
3882 .chain(RUNTIME_MIGRATIONS)
3883 .copied()
3884 .collect::<Vec<_>>();
3885 apply_migrations(&db.pool, &migrations)
3886 .await
3887 .expect("platform and runtime migrations should apply");
3888 }
3889}