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