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