1use axum::Router;
25use std::collections::HashSet;
26use std::sync::Arc;
27
28use tokio_util::sync::CancellationToken;
29use uuid::Uuid;
30
31use crate::backends::OopSpawnConfig;
32use crate::client_hub::ClientHub;
33use crate::config::ConfigProvider;
34use crate::context::GearContextBuilder;
35use crate::registry::{
36 ApiGatewayCap, GearEntry, GearRegistry, GrpcHubCap, RegistryError, RestApiCap, RunnableCap,
37 SystemCap,
38};
39use crate::runtime::{GearManager, GrpcInstallerStore, OopSpawnOptions, SystemContext};
40
41#[cfg(feature = "db")]
42use crate::registry::DatabaseCap;
43
44#[derive(Clone)]
46pub enum DbOptions {
47 None,
49 #[cfg(feature = "db")]
51 Manager(Arc<toolkit_db::DbManager>),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum RunMode {
57 Full,
59 MigrateOnly,
61}
62
63pub const TOOLKIT_DIRECTORY_ENDPOINT_ENV: &str = "TOOLKIT_DIRECTORY_ENDPOINT";
65
66pub const TOOLKIT_MODULE_CONFIG_ENV: &str = "TOOLKIT_MODULE_CONFIG";
68
69pub const DEFAULT_SHUTDOWN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(35);
75
76pub struct HostRuntime {
79 registry: GearRegistry,
80 ctx_builder: GearContextBuilder,
81 instance_id: Uuid,
82 gear_manager: Arc<GearManager>,
83 grpc_installers: Arc<GrpcInstallerStore>,
84 client_hub: Arc<ClientHub>,
85 gears_cfg: Arc<dyn ConfigProvider>,
88 dep_checker: Arc<super::readiness::DependencyChecker>,
94 rest_providers_registered: std::sync::atomic::AtomicBool,
100 cancel: CancellationToken,
101 #[allow(dead_code)]
102 db_options: DbOptions,
103 oop_options: Option<OopSpawnOptions>,
105 shutdown_deadline: std::time::Duration,
107}
108
109impl HostRuntime {
110 pub fn new(
114 registry: GearRegistry,
115 gears_cfg: Arc<dyn ConfigProvider>,
116 db_options: DbOptions,
117 client_hub: Arc<ClientHub>,
118 cancel: CancellationToken,
119 instance_id: Uuid,
120 oop_options: Option<OopSpawnOptions>,
121 ) -> Self {
122 let gear_manager = Arc::new(GearManager::new());
124 let grpc_installers = Arc::new(GrpcInstallerStore::new());
125
126 let dep_checker = Arc::new(super::readiness::DependencyChecker::new());
130 client_hub.register::<super::readiness::DependencyChecker>(dep_checker.clone());
131
132 let ctx_builder = GearContextBuilder::new(
134 instance_id,
135 gears_cfg.clone(),
136 client_hub.clone(),
137 cancel.clone(),
138 );
139 #[cfg(feature = "db")]
140 let ctx_builder = match &db_options {
141 DbOptions::Manager(mgr) => ctx_builder.with_db_manager(mgr.clone()),
142 DbOptions::None => ctx_builder,
143 };
144
145 Self {
146 registry,
147 ctx_builder,
148 instance_id,
149 gear_manager,
150 grpc_installers,
151 client_hub,
152 gears_cfg,
153 dep_checker,
154 rest_providers_registered: std::sync::atomic::AtomicBool::new(false),
155 cancel,
156 db_options,
157 oop_options,
158 shutdown_deadline: DEFAULT_SHUTDOWN_DEADLINE,
159 }
160 }
161
162 #[must_use]
179 pub fn with_shutdown_deadline(mut self, deadline: std::time::Duration) -> Self {
180 self.shutdown_deadline = deadline;
181 self
182 }
183
184 #[must_use]
190 pub fn with_internal_token_provider(
191 mut self,
192 provider: Option<toolkit_contract::runtime::config::InternalTokenProvider>,
193 ) -> Self {
194 self.ctx_builder = self.ctx_builder.with_internal_token_provider(provider);
195 self
196 }
197
198 pub fn run_pre_init_phase(&self) -> Result<(), RegistryError> {
205 tracing::info!("Phase: pre_init");
206
207 let sys_ctx = SystemContext::new(
208 self.instance_id,
209 Arc::clone(&self.gear_manager),
210 Arc::clone(&self.grpc_installers),
211 );
212
213 for entry in self.registry.gears() {
214 if self.cancel.is_cancelled() {
216 tracing::warn!("Pre-init phase cancelled by signal");
217 return Err(RegistryError::Cancelled);
218 }
219
220 if let Some(sys_mod) = entry.caps.query::<SystemCap>() {
221 tracing::debug!(gear = entry.name, "Running system pre_init");
222 sys_mod
223 .pre_init(&sys_ctx)
224 .map_err(|e| RegistryError::PreInit {
225 gear: entry.name,
226 source: e,
227 })?;
228 }
229 }
230
231 Ok(())
232 }
233
234 #[cfg(feature = "db")]
236 async fn gear_context(
237 &self,
238 gear_name: &'static str,
239 ) -> Result<crate::context::GearCtx, RegistryError> {
240 self.ctx_builder
241 .for_gear(gear_name)
242 .await
243 .map_err(|e| RegistryError::DbMigrate {
244 gear: gear_name,
245 source: e,
246 })
247 }
248
249 #[cfg(feature = "db")]
251 async fn db_migration_target(
252 &self,
253 gear_name: &'static str,
254 ctx: &crate::context::GearCtx,
255 db_gear: Option<Arc<dyn crate::contracts::DatabaseCapability>>,
256 ) -> Result<
257 Option<(
258 toolkit_db::Db,
259 Arc<dyn crate::contracts::DatabaseCapability>,
260 )>,
261 RegistryError,
262 > {
263 let Some(dbm) = db_gear else {
264 return Ok(None);
265 };
266
267 let db = match &self.db_options {
271 DbOptions::None => None,
272 #[cfg(feature = "db")]
273 DbOptions::Manager(mgr) => {
274 mgr.get(gear_name)
275 .await
276 .map_err(|e| RegistryError::DbMigrate {
277 gear: gear_name,
278 source: e.into(),
279 })?
280 }
281 };
282
283 _ = ctx; Ok(db.map(|db| (db, dbm)))
285 }
286
287 #[cfg(feature = "db")]
292 async fn migrate_gear(
293 gear_name: &'static str,
294 db: &toolkit_db::Db,
295 db_gear: Arc<dyn crate::contracts::DatabaseCapability>,
296 ) -> Result<(), RegistryError> {
297 let migrations = db_gear.migrations();
299
300 if migrations.is_empty() {
301 tracing::debug!(gear = gear_name, "No migrations to run");
302 return Ok(());
303 }
304
305 tracing::debug!(
306 gear = gear_name,
307 count = migrations.len(),
308 "Running DB migrations"
309 );
310
311 let result =
313 toolkit_db::migration_runner::run_migrations_for_gear(db, gear_name, migrations)
314 .await
315 .map_err(|e| RegistryError::DbMigrate {
316 gear: gear_name,
317 source: anyhow::Error::new(e),
318 })?;
319
320 tracing::info!(
321 gear = gear_name,
322 applied = result.applied,
323 skipped = result.skipped,
324 "DB migrations completed"
325 );
326
327 Ok(())
328 }
329
330 #[cfg(feature = "db")]
339 async fn run_db_phase(&self) -> Result<(), RegistryError> {
340 tracing::info!("Phase: db (before init)");
341
342 for entry in self.registry.gears_by_system_priority() {
343 if self.cancel.is_cancelled() {
345 tracing::warn!("DB migration phase cancelled by signal");
346 return Err(RegistryError::Cancelled);
347 }
348
349 let ctx = self.gear_context(entry.name).await?;
350 let db_gear = entry.caps.query::<DatabaseCap>();
351
352 match self
353 .db_migration_target(entry.name, &ctx, db_gear.clone())
354 .await?
355 {
356 Some((db, dbm)) => {
357 Self::migrate_gear(entry.name, &db, dbm).await?;
358 }
359 None if db_gear.is_some() => {
360 tracing::debug!(
361 gear = entry.name,
362 "Gear has DbGear trait but no DB handle (no config)"
363 );
364 }
365 None => {}
366 }
367 }
368
369 Ok(())
370 }
371
372 async fn run_init_phase(&self) -> Result<(), RegistryError> {
376 tracing::info!("Phase: init");
377
378 for entry in self.registry.gears_by_system_priority() {
379 let ctx =
380 self.ctx_builder
381 .for_gear(entry.name)
382 .await
383 .map_err(|e| RegistryError::Init {
384 gear: entry.name,
385 source: e,
386 })?;
387 tracing::info!(gear = entry.name, "Initializing a gear...");
388 entry
389 .core
390 .init(&ctx)
391 .await
392 .map_err(|e| RegistryError::Init {
393 gear: entry.name,
394 source: e,
395 })?;
396 tracing::info!(gear = entry.name, "Initialized a gear.");
397 }
398
399 Ok(())
400 }
401
402 #[allow(
417 clippy::unused_async,
418 reason = "kept async for symmetry with the other `run_*_phase` steps awaited in sequence by `run_gear_phases`; the awaited work runs in a spawned readiness-probe task"
419 )]
420 async fn run_proxy_wiring_phase(&self) -> Result<(), RegistryError> {
421 let regs: Vec<&crate::discovery::ConsumerRegistration> =
422 inventory::iter::<crate::discovery::ConsumerRegistration>
423 .into_iter()
424 .collect();
425 self.wire_consumer_registrations(®s)
426 }
427
428 fn wire_consumer_registrations(
434 &self,
435 regs: &[&crate::discovery::ConsumerRegistration],
436 ) -> Result<(), RegistryError> {
437 use crate::discovery::{DirectoryEndpointResolver, NullEndpointResolver};
438 use toolkit_contract::runtime::resolving::EndpointResolver;
439
440 if regs.is_empty() {
441 return Ok(());
442 }
443 tracing::info!(
444 count = regs.len(),
445 "Phase: proxy-wiring (consumer discovery)"
446 );
447
448 let (resolver, have_directory): (Arc<dyn EndpointResolver>, bool) =
455 if let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() {
456 (Arc::new(DirectoryEndpointResolver::new(dir)), true)
457 } else {
458 tracing::error!(
459 consumers = regs.len(),
460 "proxy-wiring: no DirectoryClient in ClientHub; remote consumer \
461 dependencies cannot be resolved and will gate /readyz (503). \
462 Co-located (local) dependencies are unaffected."
463 );
464 (Arc::new(NullEndpointResolver), false)
465 };
466
467 let known_gears: std::collections::HashSet<&str> =
479 self.registry.gears().iter().map(GearEntry::name).collect();
480 for reg in regs {
481 if !known_gears.contains(reg.owner_gear) {
482 tracing::warn!(
483 owner = reg.owner_gear,
484 dep = reg.dep_gear,
485 "proxy-wiring: consumer's owner gear name does not match any registered gear; \
486 the `gears.{}.config.consumer_wiring.{}` static override will never resolve. \
487 Rename the gear to the kebab-case of its struct ident.",
488 reg.owner_gear,
489 reg.dep_gear,
490 );
491 }
492 }
493
494 let mut remote_deps: Vec<String> = Vec::new();
495 for reg in regs {
496 let (endpoint_override, mut tuning) =
500 match read_consumer_wiring(self.gears_cfg.as_ref(), reg.owner_gear, reg.dep_gear)
501 .map_err(|source| RegistryError::ProxyWiring {
502 gear: reg.owner_gear,
503 source,
504 })? {
505 Some(wiring) => wiring.into_parts(),
506 None => (None, toolkit_contract::wiring::ClientTuning::default()),
507 };
508
509 tuning = tuning
515 .with_internal_token_provider(self.ctx_builder.internal_token_provider().cloned());
516
517 let is_static = endpoint_override.is_some();
524 let reg_resolver: Arc<dyn EndpointResolver> = if let Some(endpoint) = &endpoint_override
525 {
526 tracing::warn!(
527 owner = reg.owner_gear,
528 dep = reg.dep_gear,
529 endpoint = %endpoint,
530 "proxy-wiring: STATIC endpoint override in use (ADR-0004 dev/test \
531 escape hatch) - bypasses service discovery; MUST NOT be used in \
532 production"
533 );
534 Arc::new(crate::discovery::StaticEndpointResolver::new(
535 endpoint.clone(),
536 ))
537 } else {
538 Arc::clone(&resolver)
539 };
540
541 let outcome = (reg.wire)(&self.client_hub, reg_resolver, tuning).map_err(|source| {
542 RegistryError::ProxyWiring {
543 gear: reg.owner_gear,
544 source,
545 }
546 })?;
547 self.dep_checker.register_dep(reg.dep_gear.to_owned());
548 match outcome {
549 crate::discovery::WireOutcome::Local => {
551 self.dep_checker.mark_resolved(reg.dep_gear);
552 }
553 crate::discovery::WireOutcome::Remote if is_static => {
555 self.dep_checker.mark_resolved(reg.dep_gear);
556 }
557 crate::discovery::WireOutcome::Remote => remote_deps.push(reg.dep_gear.to_owned()),
559 }
560 tracing::debug!(
561 owner = reg.owner_gear,
562 dep = reg.dep_gear,
563 outcome = ?outcome,
564 static_override = is_static,
565 "wired consumer contract"
566 );
567 }
568
569 if !have_directory || remote_deps.is_empty() {
573 return Ok(());
574 }
575
576 let readiness = Arc::clone(&self.dep_checker);
577 let cancel = self.cancel.clone();
578 tokio::spawn(async move {
579 const BASE: std::time::Duration = std::time::Duration::from_millis(100);
580 const MAX: std::time::Duration = std::time::Duration::from_secs(30);
581 let mut pending = remote_deps;
582 let mut backoff = BASE;
583 while !pending.is_empty() {
584 let mut still_pending = Vec::new();
585 for dep in pending {
586 match resolver.resolve_endpoint(&dep).await {
587 Ok(Some(_)) => {
588 readiness.mark_resolved(&dep);
589 tracing::info!(dep = %dep, "readiness: dependency resolved");
590 }
591 Ok(None) => still_pending.push(dep),
593 Err(e) => {
596 tracing::warn!(dep = %dep, error = %e, "readiness: directory lookup failed");
597 still_pending.push(dep);
598 }
599 }
600 }
601 pending = still_pending;
602 if pending.is_empty() {
603 break;
604 }
605 tokio::select! {
606 () = cancel.cancelled() => break,
607 () = tokio::time::sleep(backoff) => {}
608 }
609 backoff = (backoff * 2).min(MAX);
610 }
611 });
612
613 Ok(())
614 }
615
616 async fn run_init_wiring_post_init(&self) -> Result<(), RegistryError> {
635 self.run_init_phase().await?;
636
637 self.run_proxy_wiring_phase().await?;
638
639 self.run_post_init_phase().await
640 }
641
642 async fn run_post_init_phase(&self) -> Result<(), RegistryError> {
645 tracing::info!("Phase: post_init");
646
647 let sys_ctx = SystemContext::new(
648 self.instance_id,
649 Arc::clone(&self.gear_manager),
650 Arc::clone(&self.grpc_installers),
651 );
652
653 for entry in self.registry.gears_by_system_priority() {
654 if let Some(sys_mod) = entry.caps.query::<SystemCap>() {
655 sys_mod
656 .post_init(&sys_ctx)
657 .await
658 .map_err(|e| RegistryError::PostInit {
659 gear: entry.name,
660 source: e,
661 })?;
662 }
663 }
664
665 Ok(())
666 }
667
668 async fn run_rest_phase(&self) -> Result<Router, RegistryError> {
675 tracing::info!("Phase: rest (sync)");
676
677 let mut router = Router::new();
678
679 let host_count = self
681 .registry
682 .gears()
683 .iter()
684 .filter(|e| e.caps.has::<ApiGatewayCap>())
685 .count();
686
687 match host_count {
688 0 => {
689 return if self
690 .registry
691 .gears()
692 .iter()
693 .any(|e| e.caps.has::<RestApiCap>())
694 {
695 Err(RegistryError::RestRequiresHost)
696 } else {
697 Ok(router)
698 };
699 }
700 1 => { }
701 _ => return Err(RegistryError::MultipleRestHosts),
702 }
703
704 let host_idx = self
706 .registry
707 .gears()
708 .iter()
709 .position(|e| e.caps.has::<ApiGatewayCap>())
710 .ok_or(RegistryError::RestHostNotFoundAfterValidation)?;
711 let host_entry = &self.registry.gears()[host_idx];
712 let Some(host) = host_entry.caps.query::<ApiGatewayCap>() else {
713 return Err(RegistryError::RestHostMissingFromEntry);
714 };
715 let host_ctx = self
716 .ctx_builder
717 .for_gear(host_entry.name)
718 .await
719 .map_err(|e| RegistryError::RestPrepare {
720 gear: host_entry.name,
721 source: e,
722 })?;
723
724 let registry: &dyn crate::contracts::OpenApiRegistry = host.as_registry();
726
727 let hc_registry = Arc::new(
731 crate::healthcheck::RestHealthcheckRegistry::with_cancellation(
732 host_ctx.cancellation_token().clone(),
733 ),
734 );
735
736 hc_registry.register(
742 "readiness",
743 Arc::new(super::readiness::ReadinessHealthcheck::new(
744 self.dep_checker.clone(),
745 )),
746 );
747
748 router = host
750 .rest_prepare(&host_ctx, router, hc_registry.clone())
751 .map_err(|source| RegistryError::RestPrepare {
752 gear: host_entry.name,
753 source,
754 })?;
755
756 for e in self.registry.gears() {
758 if let Some(rest) = e.caps.query::<RestApiCap>() {
759 let ctx = self.ctx_builder.for_gear(e.name).await.map_err(|err| {
760 RegistryError::RestRegister {
761 gear: e.name,
762 source: err,
763 }
764 })?;
765
766 router = rest
767 .register_rest(&ctx, router, registry)
768 .map_err(|source| RegistryError::RestRegister {
769 gear: e.name,
770 source,
771 })?;
772
773 if let Some(hc) = rest.healthcheck(&ctx) {
775 hc_registry.register(e.name, hc);
776 }
777 }
778 }
779
780 router = host
782 .rest_finalize(&host_ctx, router, hc_registry)
783 .map_err(|source| RegistryError::RestFinalize {
784 gear: host_entry.name,
785 source,
786 })?;
787
788 Ok(router)
789 }
790
791 async fn run_grpc_phase(&self) -> Result<(), RegistryError> {
795 tracing::info!("Phase: grpc (registration)");
796
797 if self.registry.grpc_hub.is_none() && self.registry.grpc_services.is_empty() {
799 return Ok(());
800 }
801
802 if self.registry.grpc_hub.is_none() && !self.registry.grpc_services.is_empty() {
804 return Err(RegistryError::GrpcRequiresHub);
805 }
806
807 if let Some(hub_name) = &self.registry.grpc_hub {
809 let mut gears_data = Vec::new();
810 let mut seen = HashSet::new();
811 let mut owners: std::collections::HashMap<String, String> =
815 std::collections::HashMap::new();
816
817 for (gear_name, service_gear) in &self.registry.grpc_services {
819 let ctx = self.ctx_builder.for_gear(gear_name).await.map_err(|err| {
820 RegistryError::GrpcRegister {
821 gear: gear_name.clone(),
822 source: err,
823 }
824 })?;
825
826 let installers = service_gear
827 .get_grpc_services(&ctx)
828 .await
829 .map_err(|source| RegistryError::GrpcRegister {
830 gear: gear_name.clone(),
831 source,
832 })?;
833
834 for reg in &installers {
835 if !seen.insert(reg.service_name) {
836 return Err(RegistryError::GrpcRegister {
837 gear: gear_name.clone(),
838 source: anyhow::anyhow!(
839 "Duplicate gRPC service name: {}",
840 reg.service_name
841 ),
842 });
843 }
844 owners.insert(reg.service_name.to_owned(), gear_name.clone());
845 }
846
847 gears_data.push(crate::runtime::GearInstallers {
848 gear_name: gear_name.clone(),
849 installers,
850 });
851 }
852
853 self.gear_manager
857 .merge_authoritative_grpc_service_owners(owners);
858
859 self.grpc_installers
860 .set(crate::runtime::GrpcInstallerData { gears: gears_data })
861 .map_err(|source| RegistryError::GrpcRegister {
862 gear: hub_name.clone(),
863 source,
864 })?;
865 }
866
867 Ok(())
868 }
869
870 async fn run_start_phase(&self) -> Result<(), RegistryError> {
874 tracing::info!("Phase: start");
875
876 for e in self.registry.gears_by_system_priority() {
877 if let Some(s) = e.caps.query::<RunnableCap>() {
878 tracing::debug!(
879 gear = e.name,
880 is_system = e.caps.has::<SystemCap>(),
881 "Starting stateful gear"
882 );
883 s.start(self.cancel.clone())
884 .await
885 .map_err(|source| RegistryError::Start {
886 gear: e.name,
887 source,
888 })?;
889 tracing::info!(gear = e.name, "Started gear");
890 }
891 }
892
893 Ok(())
894 }
895
896 async fn stop_one_gear(entry: &GearEntry, cancel: CancellationToken) {
898 if let Some(s) = entry.caps.query::<RunnableCap>() {
899 match s.stop(cancel).await {
900 Err(err) => {
901 tracing::warn!(gear = entry.name, error = %err, "Failed to stop gear");
902 }
903 _ => {
904 tracing::info!(gear = entry.name, "Stopped gear");
905 }
906 }
907 }
908 }
909
910 async fn run_stop_phase(&self) -> Result<(), RegistryError> {
935 tracing::info!("Phase: stop");
936
937 self.deregister_rest_providers().await;
940
941 let deadline = self.shutdown_deadline;
942
943 for e in self.registry.gears().iter().rev() {
945 let gear_name = e.name;
946
947 let deadline_token = CancellationToken::new();
950 let deadline_token_for_timeout = deadline_token.clone();
951
952 let deadline_task = tokio::spawn(async move {
954 tokio::time::sleep(deadline).await;
955 tracing::warn!(
956 gear = gear_name,
957 deadline_secs = deadline.as_secs(),
958 "Gear shutdown deadline reached, sending hard-stop signal"
959 );
960 deadline_token_for_timeout.cancel();
961 });
962
963 Self::stop_one_gear(e, deadline_token).await;
966
967 deadline_task.abort();
969 #[allow(clippy::let_underscore_must_use)]
970 let _ = deadline_task.await;
971 }
972
973 Ok(())
974 }
975
976 async fn run_stop_phase_guarded(&self) -> Result<(), RegistryError> {
981 let gear_count = u32::try_from(self.registry.gears().len().max(1)).unwrap_or(1);
982 let stop_timeout = self
983 .shutdown_deadline
984 .checked_mul(gear_count)
985 .and_then(|d| d.checked_add(std::time::Duration::from_secs(5)))
986 .unwrap_or(self.shutdown_deadline);
987
988 let (disarm_tx, disarm_rx) = std::sync::mpsc::channel::<()>();
992 std::thread::spawn(move || {
993 match disarm_rx.recv_timeout(stop_timeout) {
994 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
995 }
997 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
998 tracing::warn!(
999 timeout_secs = stop_timeout.as_secs(),
1000 "shutdown: stop phase timed out, force exiting"
1001 );
1002 std::process::exit(1);
1003 }
1004 }
1005 });
1006
1007 let stop_result = self.run_stop_phase().await;
1008 let _ = disarm_tx.send(()).ok();
1012
1013 stop_result
1014 }
1015
1016 async fn run_oop_spawn_phase(&self) -> Result<(), RegistryError> {
1021 let oop_opts = match &self.oop_options {
1022 Some(opts) if !opts.gears.is_empty() => opts,
1023 _ => return Ok(()),
1024 };
1025
1026 tracing::info!("Phase: oop_spawn");
1027
1028 let directory_endpoint = self.wait_for_grpc_hub_endpoint().await;
1030
1031 for gear_cfg in &oop_opts.gears {
1032 let mut env = gear_cfg.env.clone();
1035 env.insert(
1036 TOOLKIT_MODULE_CONFIG_ENV.to_owned(),
1037 gear_cfg.rendered_config_json.clone(),
1038 );
1039 if let Some(ref endpoint) = directory_endpoint {
1040 env.insert(TOOLKIT_DIRECTORY_ENDPOINT_ENV.to_owned(), endpoint.clone());
1041 }
1042
1043 let args = gear_cfg.args.clone();
1045
1046 let spawn_config = OopSpawnConfig {
1047 gear_name: gear_cfg.gear_name.clone(),
1048 binary: gear_cfg.binary.clone(),
1049 args,
1050 env,
1051 working_directory: gear_cfg.working_directory.clone(),
1052 };
1053
1054 oop_opts
1055 .backend
1056 .spawn(spawn_config)
1057 .await
1058 .map_err(|e| RegistryError::OopSpawn {
1059 gear: gear_cfg.gear_name.clone(),
1060 source: e,
1061 })?;
1062
1063 tracing::info!(
1064 gear = %gear_cfg.gear_name,
1065 directory_endpoint = ?directory_endpoint,
1066 "Spawned OoP gear via backend"
1067 );
1068 }
1069
1070 Ok(())
1071 }
1072
1073 async fn wait_for_grpc_hub_endpoint(&self) -> Option<String> {
1078 const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1079 const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
1080
1081 let grpc_hub = self
1083 .registry
1084 .gears()
1085 .iter()
1086 .find_map(|e| e.caps.query::<GrpcHubCap>());
1087
1088 let Some(hub) = grpc_hub else {
1089 return None; };
1091
1092 let start = std::time::Instant::now();
1093
1094 loop {
1095 if let Some(endpoint) = hub.bound_endpoint() {
1096 tracing::debug!(
1097 endpoint = %endpoint,
1098 elapsed_ms = start.elapsed().as_millis(),
1099 "gRPC hub endpoint available"
1100 );
1101 return Some(endpoint);
1102 }
1103
1104 if hub.serves_no_endpoint() {
1105 tracing::debug!("gRPC hub binds no endpoint");
1106 return None;
1107 }
1108 if start.elapsed() > MAX_WAIT {
1109 tracing::warn!("Timed out waiting for gRPC hub to bind");
1110 return None;
1111 }
1112
1113 tokio::time::sleep(POLL_INTERVAL).await;
1114 }
1115 }
1116
1117 async fn wait_for_rest_endpoint(
1123 &self,
1124 host: &Arc<dyn crate::contracts::ApiGatewayCapability>,
1125 ) -> Option<String> {
1126 const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1127 const MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
1128
1129 let start = std::time::Instant::now();
1130 loop {
1131 if let Some(endpoint) = host.bound_endpoint() {
1132 return Some(endpoint);
1133 }
1134 if start.elapsed() > MAX_WAIT {
1135 tracing::warn!("Timed out waiting for REST host to bind");
1136 return None;
1137 }
1138 tokio::time::sleep(POLL_INTERVAL).await;
1139 }
1140 }
1141
1142 async fn run_directory_register_phase(&self) -> Result<(), RegistryError> {
1155 let rest_gears = self.rest_provider_gears();
1156 if rest_gears.is_empty() {
1157 return Ok(());
1158 }
1159
1160 let Some(host) = self
1161 .registry
1162 .gears()
1163 .iter()
1164 .find_map(|e| e.caps.query::<ApiGatewayCap>())
1165 else {
1166 return Ok(()); };
1168
1169 let Some(endpoint) = self.wait_for_rest_endpoint(&host).await else {
1170 tracing::warn!(
1171 "directory-register: REST host endpoint unavailable; skipping REST provider registration"
1172 );
1173 return Ok(());
1174 };
1175
1176 let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() else {
1177 tracing::debug!(
1178 "directory-register: no DirectoryClient in ClientHub; skipping REST provider registration"
1179 );
1180 return Ok(());
1181 };
1182
1183 let instance_id = self.instance_id.to_string();
1184 for gear in rest_gears {
1185 let (grpc_services, version) = match dir.list_instances(gear).await {
1198 Ok(insts) => insts
1199 .into_iter()
1200 .find(|i| i.instance_id == instance_id)
1201 .map(|i| (i.grpc_services, i.version))
1202 .unwrap_or_default(),
1203 Err(e) => {
1204 tracing::warn!(
1208 gear,
1209 error = %e,
1210 "directory-register: failed to read existing registration; \
1211 re-registering with empty grpc_services/version"
1212 );
1213 (Vec::new(), None)
1214 }
1215 };
1216 let mut info = crate::RegisterInstanceInfo::new(gear.to_owned(), instance_id.clone())
1220 .with_grpc_services(grpc_services)
1221 .with_rest_endpoint(crate::ServiceEndpoint::new(endpoint.clone()));
1222 if let Some(version) = version {
1223 info = info.with_version(version);
1224 }
1225 match dir.register_instance(info).await {
1226 Ok(()) => {
1227 tracing::info!(gear, endpoint = %endpoint, "registered REST provider in directory");
1228 }
1229 Err(e) => {
1230 tracing::warn!(gear, error = %e, "directory-register: failed to register REST provider");
1231 }
1232 }
1233 }
1234 self.rest_providers_registered
1238 .store(true, std::sync::atomic::Ordering::SeqCst);
1239 Ok(())
1240 }
1241
1242 fn rest_provider_gears(&self) -> Vec<&'static str> {
1247 self.registry
1248 .gears()
1249 .iter()
1250 .filter(|e| e.caps.has::<RestApiCap>() && !e.caps.has::<ApiGatewayCap>())
1251 .map(|e| e.name)
1252 .collect()
1253 }
1254
1255 async fn deregister_rest_providers(&self) {
1258 if !self
1262 .rest_providers_registered
1263 .load(std::sync::atomic::Ordering::SeqCst)
1264 {
1265 return;
1266 }
1267 let rest_gears = self.rest_provider_gears();
1268 if rest_gears.is_empty() {
1269 return;
1270 }
1271 let Ok(dir) = self.client_hub.get::<dyn crate::DirectoryClient>() else {
1272 return;
1273 };
1274 let instance_id = self.instance_id.to_string();
1275 for gear in rest_gears {
1276 if let Err(e) = dir.deregister_instance(gear, &instance_id).await {
1277 tracing::warn!(gear, error = %e, "directory-deregister: failed to deregister REST provider");
1278 }
1279 }
1280 }
1281
1282 pub async fn run_gear_phases(self) -> anyhow::Result<()> {
1291 self.run_phases_internal(RunMode::Full).await
1292 }
1293
1294 pub async fn run_migration_phases(self) -> anyhow::Result<()> {
1304 self.run_phases_internal(RunMode::MigrateOnly).await
1305 }
1306
1307 async fn run_phases_internal(self, mode: RunMode) -> anyhow::Result<()> {
1330 match mode {
1332 RunMode::Full => {
1333 tracing::info!("Running full lifecycle (all phases)");
1334 }
1335 RunMode::MigrateOnly => {
1336 tracing::info!("Running in migration mode (pre-init + db phases only)");
1337 }
1338 }
1339
1340 self.run_pre_init_phase()?;
1342
1343 #[cfg(feature = "db")]
1345 {
1346 self.run_db_phase().await?;
1347 }
1348 #[cfg(not(feature = "db"))]
1349 {
1350 }
1352
1353 if mode == RunMode::MigrateOnly {
1355 tracing::info!("Migration phases completed successfully");
1356 return Ok(());
1357 }
1358
1359 self.run_init_wiring_post_init().await?;
1361
1362 let _router = self.run_rest_phase().await?;
1364
1365 self.run_grpc_phase().await?;
1367
1368 self.run_start_phase().await?;
1370
1371 {
1375 let readiness = Arc::clone(&self.dep_checker);
1376 let cancel = self.cancel.clone();
1377 tokio::spawn(async move {
1378 cancel.cancelled().await;
1379 readiness.set_draining(true);
1380 });
1381 }
1382
1383 self.run_directory_register_phase().await?;
1386
1387 self.run_oop_spawn_phase().await?;
1389
1390 self.cancel.cancelled().await;
1392
1393 self.run_stop_phase_guarded().await?;
1397 Ok(())
1398 }
1399}
1400
1401#[cfg(feature = "bootstrap")]
1403type ComposedOopRouter = (Router, String, toolkit_http_middleware::RouteAuthPolicy);
1404
1405#[cfg(feature = "bootstrap")]
1407impl HostRuntime {
1408 async fn compose_oop_router(
1414 &self,
1415 options: &crate::runtime::OopServeOptions,
1416 hc_registry: &Arc<crate::healthcheck::RestHealthcheckRegistry>,
1417 ) -> anyhow::Result<ComposedOopRouter> {
1418 use crate::api::{OpenApiInfo, OpenApiRegistryImpl};
1419 use anyhow::Context as _;
1420
1421 let registry = OpenApiRegistryImpl::new();
1422 let mut router = Router::new();
1423
1424 for entry in self.registry.gears() {
1425 if let Some(rest) = entry.caps.query::<RestApiCap>() {
1426 let ctx = self
1427 .ctx_builder
1428 .for_gear(entry.name)
1429 .await
1430 .with_context(|| format!("OoP router: build context for '{}'", entry.name))?;
1431 router = rest
1432 .register_rest(&ctx, router, ®istry)
1433 .with_context(|| format!("OoP router: register_rest for '{}'", entry.name))?;
1434
1435 if let Some(hc) = rest.healthcheck(&ctx) {
1439 hc_registry.register(entry.name, hc);
1440 }
1441 }
1442 }
1443
1444 let info = OpenApiInfo {
1445 title: options.gear_name.clone(),
1446 version: options
1447 .version
1448 .clone()
1449 .unwrap_or_else(|| "0.0.0".to_owned()),
1450 description: None,
1451 servers: vec![],
1452 };
1453 let openapi = registry
1454 .build_openapi(&info)
1455 .context("OoP router: build OpenAPI document")?;
1456 let json = serde_json::to_string(&openapi).context("OoP router: serialize OpenAPI")?;
1457 let route_auth = super::oop_serve::route_auth_policy(registry.operation_specs.iter());
1458
1459 Ok((router, json, route_auth))
1460 }
1461
1462 pub async fn run_oop_serving(
1470 self,
1471 options: crate::runtime::OopServeOptions,
1472 ) -> anyhow::Result<()> {
1473 use crate::runtime::ReadinessState;
1474
1475 tracing::info!("Running OoP serving lifecycle");
1476
1477 if self.client_hub.get::<dyn crate::DirectoryClient>().is_err() {
1482 self.client_hub
1483 .register::<dyn crate::DirectoryClient>(Arc::clone(&options.directory));
1484 }
1485
1486 let hc_registry = Arc::new(
1490 crate::healthcheck::RestHealthcheckRegistry::with_cancellation(self.cancel.clone()),
1491 );
1492
1493 let readiness = ReadinessState::from_checker(
1500 Arc::clone(&self.dep_checker),
1501 Arc::clone(&hc_registry),
1502 options.healthcheck_timeout,
1503 );
1504
1505 let mut server = super::oop_serve::OopHttpServer::start(
1510 Arc::clone(&readiness),
1511 options,
1512 self.cancel.clone(),
1513 )
1514 .await?;
1515
1516 let mut started = false;
1522 let composed: anyhow::Result<ComposedOopRouter> = async {
1523 self.run_pre_init_phase()?;
1524 #[cfg(feature = "db")]
1525 self.run_db_phase().await?;
1526 self.run_init_wiring_post_init().await?;
1530 self.run_grpc_phase().await?;
1531 self.run_start_phase().await?;
1532 started = true;
1533 server.resolve_bearer_authenticator(&self.client_hub);
1539 self.compose_oop_router(server.options(), &hc_registry)
1540 .await
1541 }
1542 .await;
1543
1544 let serve_result = match composed {
1545 Ok((gear_router, openapi_json, route_auth)) => {
1546 server.attach(gear_router, openapi_json, route_auth);
1549 server.join().await
1551 }
1552 Err(e) => {
1553 tracing::error!(error = %e, "OoP startup failed before serving gear routes");
1554 self.cancel.cancel();
1556 if let Err(join_err) = server.join().await {
1557 tracing::warn!(error = %join_err, "OoP probe server teardown after startup failure errored");
1558 }
1559 Err(e)
1560 }
1561 };
1562
1563 if started && let Err(e) = self.run_stop_phase_guarded().await {
1566 tracing::warn!(error = %e, "OoP stop phase reported an error");
1567 }
1568
1569 serve_result
1570 }
1571}
1572
1573fn read_consumer_wiring(
1579 cfg: &dyn ConfigProvider,
1580 owner_gear: &str,
1581 dep_gear: &str,
1582) -> Result<Option<toolkit_contract::wiring::ConsumerWiring>, anyhow::Error> {
1583 let Some(value) = cfg
1584 .get_gear_config(owner_gear)
1585 .and_then(|c| c.get("config"))
1586 .and_then(|c| c.get("consumer_wiring"))
1587 .and_then(|c| c.get(dep_gear))
1588 else {
1589 return Ok(None);
1590 };
1591 if let Some(s) = value.as_str() {
1594 anyhow::bail!(
1595 "gear `{owner_gear}`: `consumer_wiring.{dep_gear}` is a bare string; the \
1596 static-endpoint override now lives on the object form — write it as \
1597 `{{ \"endpoint\": \"{s}\" }}`"
1598 );
1599 }
1600 serde_json::from_value::<toolkit_contract::wiring::ConsumerWiring>(value.clone())
1601 .map(Some)
1602 .map_err(|e| {
1603 anyhow::anyhow!("gear `{owner_gear}`: invalid consumer_wiring.{dep_gear}: {e}")
1604 })
1605}
1606
1607#[cfg(test)]
1608#[cfg(feature = "bootstrap")]
1609#[cfg_attr(coverage_nightly, coverage(off))]
1610#[path = "host_runtime_oop_tests.rs"]
1611mod host_runtime_oop_tests;
1612
1613#[cfg(test)]
1614#[cfg_attr(coverage_nightly, coverage(off))]
1615mod tests {
1616 use super::*;
1617 use crate::context::GearCtx;
1618 use crate::contracts::{Gear, RunnableCapability, SystemCapability};
1619 use crate::registry::RegistryBuilder;
1620 use std::sync::Arc;
1621 use std::sync::atomic::{AtomicUsize, Ordering};
1622 use tokio::sync::Mutex;
1623
1624 #[derive(Default)]
1625 #[allow(dead_code)]
1626 struct DummyCore;
1627 #[async_trait::async_trait]
1628 impl Gear for DummyCore {
1629 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1630 Ok(())
1631 }
1632 }
1633
1634 struct StopOrderTracker {
1635 my_order: usize,
1636 stop_order: Arc<AtomicUsize>,
1637 }
1638
1639 impl StopOrderTracker {
1640 fn new(counter: &Arc<AtomicUsize>, stop_order: Arc<AtomicUsize>) -> Self {
1641 let my_order = counter.fetch_add(1, Ordering::SeqCst);
1642 Self {
1643 my_order,
1644 stop_order,
1645 }
1646 }
1647 }
1648
1649 #[async_trait::async_trait]
1650 impl Gear for StopOrderTracker {
1651 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1652 Ok(())
1653 }
1654 }
1655
1656 #[async_trait::async_trait]
1657 impl RunnableCapability for StopOrderTracker {
1658 async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1659 Ok(())
1660 }
1661 async fn stop(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1662 let order = self.stop_order.fetch_add(1, Ordering::SeqCst);
1663 tracing::info!(my_order = self.my_order, stop_order = order, "Gear stopped");
1664 Ok(())
1665 }
1666 }
1667
1668 #[tokio::test]
1669 async fn test_stop_phase_reverse_order() {
1670 let counter = Arc::new(AtomicUsize::new(0));
1671 let stop_order = Arc::new(AtomicUsize::new(0));
1672
1673 let gear_a = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1674 let gear_b = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1675 let gear_c = Arc::new(StopOrderTracker::new(&counter, stop_order.clone()));
1676
1677 let mut builder = RegistryBuilder::default();
1678 builder.register_core_with_meta("a", &[], gear_a.clone() as Arc<dyn Gear>);
1679 builder.register_core_with_meta("b", &["a"], gear_b.clone() as Arc<dyn Gear>);
1680 builder.register_core_with_meta("c", &["b"], gear_c.clone() as Arc<dyn Gear>);
1681
1682 builder.register_stateful_with_meta("a", gear_a.clone() as Arc<dyn RunnableCapability>);
1683 builder.register_stateful_with_meta("b", gear_b.clone() as Arc<dyn RunnableCapability>);
1684 builder.register_stateful_with_meta("c", gear_c.clone() as Arc<dyn RunnableCapability>);
1685
1686 let registry = builder.build_topo_sorted().unwrap();
1687
1688 let gear_names: Vec<_> = registry.gears().iter().map(|m| m.name).collect();
1690 assert_eq!(gear_names, vec!["a", "b", "c"]);
1691
1692 let client_hub = Arc::new(ClientHub::new());
1693 let cancel = CancellationToken::new();
1694 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
1695
1696 let runtime = HostRuntime::new(
1697 registry,
1698 config_provider,
1699 DbOptions::None,
1700 client_hub,
1701 cancel.clone(),
1702 Uuid::new_v4(),
1703 None,
1704 );
1705
1706 runtime.run_stop_phase().await.unwrap();
1708
1709 assert_eq!(stop_order.load(Ordering::SeqCst), 3);
1713 }
1714
1715 #[tokio::test]
1716 async fn test_stop_phase_continues_on_error() {
1717 struct FailingGear {
1718 should_fail: bool,
1719 stopped: Arc<AtomicUsize>,
1720 }
1721
1722 #[async_trait::async_trait]
1723 impl Gear for FailingGear {
1724 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
1725 Ok(())
1726 }
1727 }
1728
1729 #[async_trait::async_trait]
1730 impl RunnableCapability for FailingGear {
1731 async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1732 Ok(())
1733 }
1734 async fn stop(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
1735 self.stopped.fetch_add(1, Ordering::SeqCst);
1736 if self.should_fail {
1737 anyhow::bail!("Intentional failure")
1738 }
1739 Ok(())
1740 }
1741 }
1742
1743 let stopped = Arc::new(AtomicUsize::new(0));
1744 let gear_a = Arc::new(FailingGear {
1745 should_fail: false,
1746 stopped: stopped.clone(),
1747 });
1748 let gear_b = Arc::new(FailingGear {
1749 should_fail: true,
1750 stopped: stopped.clone(),
1751 });
1752 let gear_c = Arc::new(FailingGear {
1753 should_fail: false,
1754 stopped: stopped.clone(),
1755 });
1756
1757 let mut builder = RegistryBuilder::default();
1758 builder.register_core_with_meta("a", &[], gear_a.clone() as Arc<dyn Gear>);
1759 builder.register_core_with_meta("b", &["a"], gear_b.clone() as Arc<dyn Gear>);
1760 builder.register_core_with_meta("c", &["b"], gear_c.clone() as Arc<dyn Gear>);
1761
1762 builder.register_stateful_with_meta("a", gear_a.clone() as Arc<dyn RunnableCapability>);
1763 builder.register_stateful_with_meta("b", gear_b.clone() as Arc<dyn RunnableCapability>);
1764 builder.register_stateful_with_meta("c", gear_c.clone() as Arc<dyn RunnableCapability>);
1765
1766 let registry = builder.build_topo_sorted().unwrap();
1767
1768 let client_hub = Arc::new(ClientHub::new());
1769 let cancel = CancellationToken::new();
1770 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
1771
1772 let runtime = HostRuntime::new(
1773 registry,
1774 config_provider,
1775 DbOptions::None,
1776 client_hub,
1777 cancel.clone(),
1778 Uuid::new_v4(),
1779 None,
1780 );
1781
1782 runtime.run_stop_phase().await.unwrap();
1784
1785 assert_eq!(stopped.load(Ordering::SeqCst), 3);
1787 }
1788
1789 struct EmptyConfigProvider;
1790 impl ConfigProvider for EmptyConfigProvider {
1791 fn get_gear_config(&self, _gear_name: &str) -> Option<&serde_json::Value> {
1792 None
1793 }
1794 }
1795
1796 struct OrdersCfg(serde_json::Value);
1799 impl ConfigProvider for OrdersCfg {
1800 fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> {
1801 (gear == "orders").then_some(&self.0)
1802 }
1803 }
1804
1805 struct MapCfg(std::collections::HashMap<String, serde_json::Value>);
1807 impl ConfigProvider for MapCfg {
1808 fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> {
1809 self.0.get(gear)
1810 }
1811 }
1812
1813 fn endpoint_override(cfg: &dyn ConfigProvider, owner: &str, dep: &str) -> Option<String> {
1815 super::read_consumer_wiring(cfg, owner, dep)
1816 .expect("consumer_wiring must not error for a valid/absent endpoint")
1817 .and_then(|w| w.into_parts().0)
1818 }
1819
1820 #[test]
1821 fn static_endpoint_override_reads_nested_consumer_wiring_key() {
1822 let mut map = std::collections::HashMap::new();
1823 map.insert(
1824 "orders".to_owned(),
1825 serde_json::json!({
1826 "config": {
1827 "consumer_wiring": { "billing": { "endpoint": "http://localhost:8081" } }
1828 }
1829 }),
1830 );
1831 let cfg = MapCfg(map);
1832
1833 assert_eq!(
1835 endpoint_override(&cfg, "orders", "billing").as_deref(),
1836 Some("http://localhost:8081")
1837 );
1838 assert_eq!(endpoint_override(&cfg, "orders", "inventory"), None);
1840 assert_eq!(endpoint_override(&cfg, "warehouse", "billing"), None);
1841 assert_eq!(
1842 endpoint_override(&EmptyConfigProvider, "orders", "billing"),
1843 None
1844 );
1845 }
1846
1847 #[test]
1850 fn consumer_wiring_object_form_yields_endpoint_and_tuning() {
1851 let cfg = OrdersCfg(serde_json::json!({
1852 "config": {
1853 "consumer_wiring": {
1854 "billing": {
1855 "endpoint": "http://billing:8080",
1856 "timeout": "5s",
1857 "max_concurrent_requests": 256,
1858 "pool_max_idle_per_host": 256
1859 }
1860 }
1861 }
1862 }));
1863
1864 let (endpoint, tuning) = super::read_consumer_wiring(&cfg, "orders", "billing")
1865 .expect("wiring must parse")
1866 .expect("wiring present")
1867 .into_parts();
1868 assert_eq!(endpoint.as_deref(), Some("http://billing:8080"));
1869 assert_eq!(tuning.timeout, Some(std::time::Duration::from_secs(5)));
1870 assert_eq!(tuning.max_concurrent_requests, Some(256));
1871 assert_eq!(tuning.pool_max_idle_per_host, Some(256));
1872 }
1873
1874 #[test]
1876 fn consumer_wiring_object_without_endpoint_keeps_discovery() {
1877 let cfg = OrdersCfg(serde_json::json!({
1878 "config": {
1879 "consumer_wiring": { "billing": { "max_concurrent_requests": 1 } }
1880 }
1881 }));
1882
1883 let (endpoint, tuning) = super::read_consumer_wiring(&cfg, "orders", "billing")
1884 .expect("wiring must parse")
1885 .expect("wiring present")
1886 .into_parts();
1887 assert_eq!(endpoint, None, "omitted endpoint must keep discovery");
1888 assert_eq!(tuning.max_concurrent_requests, Some(1));
1889 }
1890
1891 #[test]
1895 fn consumer_wiring_malformed_entry_is_an_error() {
1896 let malformed = [
1897 serde_json::json!([1, 2, 3]),
1899 serde_json::json!({ "max_concurrent_requests": "lots" }),
1902 serde_json::json!({ "timeout": 5 }),
1903 serde_json::json!({ "endpoint": "http://billing:8080", "timeout": 5 }),
1906 ];
1907 for entry in malformed {
1908 let cfg = OrdersCfg(serde_json::json!({
1909 "config": { "consumer_wiring": { "billing": entry.clone() } }
1910 }));
1911 assert!(
1912 super::read_consumer_wiring(&cfg, "orders", "billing").is_err(),
1913 "a malformed entry (`{entry}`) must be a boot error, not silently dropped"
1914 );
1915 }
1916 }
1917
1918 #[test]
1921 fn consumer_wiring_legacy_bare_string_is_an_error_naming_the_object_form() {
1922 let cfg = OrdersCfg(serde_json::json!({
1923 "config": { "consumer_wiring": { "billing": "http://localhost:8081" } }
1924 }));
1925 let err = super::read_consumer_wiring(&cfg, "orders", "billing")
1926 .expect_err("a bare-string value is the legacy shape -> hard error")
1927 .to_string();
1928 assert!(
1929 err.contains("endpoint"),
1930 "the error must point the operator at the `{{ endpoint: ... }}` object form, got: {err}"
1931 );
1932 }
1933
1934 #[test]
1937 fn consumer_wiring_invalid_endpoint_is_an_error() {
1938 for bad in ["", "billing", "/path/only"] {
1939 let cfg = OrdersCfg(serde_json::json!({
1940 "config": { "consumer_wiring": { "billing": { "endpoint": bad } } }
1941 }));
1942 assert!(
1943 super::read_consumer_wiring(&cfg, "orders", "billing").is_err(),
1944 "a present-but-invalid endpoint (`{bad}`) must be a hard error, not survived"
1945 );
1946 }
1947 }
1948
1949 #[derive(Clone)]
1953 struct WiredCall {
1954 resolver: Arc<dyn crate::discovery::EndpointResolver>,
1955 max_concurrent_requests: Option<usize>,
1956 has_credential: bool,
1959 }
1960 static WIRED: std::sync::Mutex<Vec<WiredCall>> = std::sync::Mutex::new(Vec::new());
1961
1962 #[allow(
1965 clippy::unnecessary_wraps,
1966 clippy::needless_pass_by_value,
1967 reason = "must match the ConsumerRegistration::wire signature the macro emits"
1968 )]
1969 fn record_wire(
1970 _hub: &ClientHub,
1971 resolver: Arc<dyn crate::discovery::EndpointResolver>,
1972 tuning: toolkit_contract::wiring::ClientTuning,
1973 ) -> anyhow::Result<crate::discovery::WireOutcome> {
1974 WIRED.lock().unwrap().push(WiredCall {
1975 resolver,
1976 max_concurrent_requests: tuning.max_concurrent_requests,
1977 has_credential: tuning.internal_token_provider.is_some(),
1978 });
1979 Ok(crate::discovery::WireOutcome::Remote)
1980 }
1981
1982 fn test_registration() -> crate::discovery::ConsumerRegistration {
1986 crate::discovery::ConsumerRegistration {
1987 owner_gear: "orders",
1988 dep_gear: "billing",
1989 wire: record_wire,
1990 }
1991 }
1992
1993 #[tokio::test]
1997 async fn proxy_wiring_static_override_uses_static_resolver_and_marks_ready() {
1998 let mut map = std::collections::HashMap::new();
1999 map.insert(
2000 "orders".to_owned(),
2001 serde_json::json!({
2002 "config": {
2003 "consumer_wiring": {
2004 "billing": {
2005 "endpoint": "http://localhost:8081",
2006 "max_concurrent_requests": 7
2007 }
2008 }
2009 }
2010 }),
2011 );
2012
2013 let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2014 let runtime = HostRuntime::new(
2015 registry,
2016 Arc::new(MapCfg(map)),
2017 DbOptions::None,
2018 Arc::new(ClientHub::new()),
2019 CancellationToken::new(),
2020 Uuid::new_v4(),
2021 None,
2022 );
2023
2024 let reg = test_registration();
2025 runtime.wire_consumer_registrations(&[®]).unwrap();
2026
2027 let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2032 let mut matched = None;
2033 for call in &calls {
2034 if let Ok(Some(ep)) = call.resolver.resolve_endpoint("billing").await
2035 && ep == "http://localhost:8081"
2036 {
2037 matched = Some(call.clone());
2038 break;
2039 }
2040 }
2041 let matched = matched
2042 .expect("static override must wire a resolver pinned to the configured endpoint");
2043 assert_eq!(
2044 matched.max_concurrent_requests,
2045 Some(7),
2046 "the tuning on the same wire call as the static endpoint must be the configured one"
2047 );
2048
2049 assert!(runtime.dep_checker.all_resolved());
2052 assert!(runtime.dep_checker.unresolved_deps().is_empty());
2053 }
2054
2055 #[tokio::test]
2059 async fn proxy_wiring_tuning_only_entry_keeps_discovery_and_gates_readiness() {
2060 let mut map = std::collections::HashMap::new();
2061 map.insert(
2064 "orders".to_owned(),
2065 serde_json::json!({
2066 "config": {
2067 "consumer_wiring": { "billing": { "max_concurrent_requests": 4343 } }
2068 }
2069 }),
2070 );
2071
2072 let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2073 let runtime = HostRuntime::new(
2074 registry,
2075 Arc::new(MapCfg(map)),
2076 DbOptions::None,
2077 Arc::new(ClientHub::new()),
2078 CancellationToken::new(),
2079 Uuid::new_v4(),
2080 None,
2081 );
2082
2083 let reg = test_registration();
2084 runtime.wire_consumer_registrations(&[®]).unwrap();
2085
2086 let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2087 let ours = calls
2088 .iter()
2089 .find(|c| c.max_concurrent_requests == Some(4343))
2090 .expect("our tuning-only wire call must have been recorded");
2091 assert!(
2092 matches!(ours.resolver.resolve_endpoint("billing").await, Ok(None)),
2093 "a tuning-only entry must keep discovery -- the resolver must not be pinned to a \
2094 static endpoint"
2095 );
2096
2097 assert!(!runtime.dep_checker.all_resolved());
2099 assert!(
2100 runtime
2101 .dep_checker
2102 .unresolved_deps()
2103 .contains(&"billing".to_owned()),
2104 "a discovery-wired dep with no directory must gate readiness"
2105 );
2106 }
2107
2108 #[tokio::test]
2112 async fn proxy_wiring_threads_platform_credential_onto_tuning() {
2113 use toolkit_contract::runtime::config::{CredentialState, InternalTokenProvider};
2114
2115 let mut map = std::collections::HashMap::new();
2116 map.insert(
2117 "orders".to_owned(),
2118 serde_json::json!({
2119 "config": {
2120 "consumer_wiring": { "billing": { "max_concurrent_requests": 4242 } }
2121 }
2122 }),
2123 );
2124
2125 let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2126 let provider = InternalTokenProvider::new(|| CredentialState::NotConfigured);
2127 let runtime = HostRuntime::new(
2128 registry,
2129 Arc::new(MapCfg(map)),
2130 DbOptions::None,
2131 Arc::new(ClientHub::new()),
2132 CancellationToken::new(),
2133 Uuid::new_v4(),
2134 None,
2135 )
2136 .with_internal_token_provider(Some(provider));
2137
2138 let reg = test_registration();
2139 runtime.wire_consumer_registrations(&[®]).unwrap();
2140
2141 let calls: Vec<WiredCall> = WIRED.lock().unwrap().clone();
2142 let ours = calls
2143 .iter()
2144 .find(|c| c.max_concurrent_requests == Some(4242))
2145 .expect("our wire call must have been recorded");
2146 assert!(
2147 ours.has_credential,
2148 "the process credential must be threaded onto the tuning handed to the wire fn"
2149 );
2150 }
2151
2152 #[tokio::test]
2156 async fn proxy_wiring_invalid_endpoint_is_a_hard_error() {
2157 let mut map = std::collections::HashMap::new();
2158 map.insert(
2159 "orders".to_owned(),
2160 serde_json::json!({
2161 "config": {
2162 "consumer_wiring": {
2163 "billing": { "endpoint": "", "max_concurrent_requests": 4444 }
2164 }
2165 }
2166 }),
2167 );
2168
2169 let registry = RegistryBuilder::default().build_topo_sorted().unwrap();
2170 let runtime = HostRuntime::new(
2171 registry,
2172 Arc::new(MapCfg(map)),
2173 DbOptions::None,
2174 Arc::new(ClientHub::new()),
2175 CancellationToken::new(),
2176 Uuid::new_v4(),
2177 None,
2178 );
2179
2180 let reg = test_registration();
2181 let err = runtime
2182 .wire_consumer_registrations(&[®])
2183 .expect_err("an invalid static endpoint must abort proxy-wiring");
2184 assert!(
2185 matches!(err, RegistryError::ProxyWiring { gear, .. } if gear == "orders"),
2186 "expected a ProxyWiring error for the owning gear, got {err:?}"
2187 );
2188 }
2189
2190 #[test]
2194 fn static_endpoint_override_is_keyed_by_kebab_gear_name() {
2195 let mut map = std::collections::HashMap::new();
2196 map.insert(
2197 "api-contracts-consumer".to_owned(),
2198 serde_json::json!({
2199 "config": {
2200 "consumer_wiring": {
2201 "api-contracts": { "endpoint": "http://localhost:9099" }
2202 }
2203 }
2204 }),
2205 );
2206 let cfg = MapCfg(map);
2207
2208 assert_eq!(
2209 endpoint_override(&cfg, "api-contracts-consumer", "api-contracts").as_deref(),
2210 Some("http://localhost:9099"),
2211 );
2212 assert_eq!(
2214 endpoint_override(&cfg, "ApiContractsConsumer", "api-contracts"),
2215 None,
2216 );
2217 }
2218
2219 #[tokio::test]
2220 async fn test_post_init_runs_after_all_init_and_system_first() {
2221 #[derive(Clone)]
2222 struct TrackHooks {
2223 name: &'static str,
2224 events: Arc<Mutex<Vec<String>>>,
2225 }
2226
2227 #[async_trait::async_trait]
2228 impl Gear for TrackHooks {
2229 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2230 self.events.lock().await.push(format!("init:{}", self.name));
2231 Ok(())
2232 }
2233 }
2234
2235 #[async_trait::async_trait]
2236 impl SystemCapability for TrackHooks {
2237 fn pre_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2238 Ok(())
2239 }
2240
2241 async fn post_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2242 self.events
2243 .lock()
2244 .await
2245 .push(format!("post_init:{}", self.name));
2246 Ok(())
2247 }
2248 }
2249
2250 let events = Arc::new(Mutex::new(Vec::<String>::new()));
2251 let sys_a = Arc::new(TrackHooks {
2252 name: "sys_a",
2253 events: events.clone(),
2254 });
2255 let user_b = Arc::new(TrackHooks {
2256 name: "user_b",
2257 events: events.clone(),
2258 });
2259 let user_c = Arc::new(TrackHooks {
2260 name: "user_c",
2261 events: events.clone(),
2262 });
2263
2264 let mut builder = RegistryBuilder::default();
2265 builder.register_core_with_meta("sys_a", &[], sys_a.clone() as Arc<dyn Gear>);
2266 builder.register_core_with_meta("user_b", &["sys_a"], user_b.clone() as Arc<dyn Gear>);
2267 builder.register_core_with_meta("user_c", &["user_b"], user_c.clone() as Arc<dyn Gear>);
2268 builder.register_system_with_meta("sys_a", sys_a.clone() as Arc<dyn SystemCapability>);
2269
2270 let registry = builder.build_topo_sorted().unwrap();
2271
2272 let client_hub = Arc::new(ClientHub::new());
2273 let cancel = CancellationToken::new();
2274 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2275
2276 let runtime = HostRuntime::new(
2277 registry,
2278 config_provider,
2279 DbOptions::None,
2280 client_hub,
2281 cancel,
2282 Uuid::new_v4(),
2283 None,
2284 );
2285
2286 runtime.run_init_phase().await.unwrap();
2288 runtime.run_post_init_phase().await.unwrap();
2289
2290 let events = events.lock().await.clone();
2291 let first_post_init = events
2292 .iter()
2293 .position(|e| e.starts_with("post_init:"))
2294 .expect("expected post_init events");
2295 assert!(
2296 events[..first_post_init]
2297 .iter()
2298 .all(|e| e.starts_with("init:")),
2299 "expected all init events before post_init, got: {events:?}"
2300 );
2301
2302 assert_eq!(
2304 events,
2305 vec![
2306 "init:sys_a",
2307 "init:user_b",
2308 "init:user_c",
2309 "post_init:sys_a",
2310 ]
2311 );
2312 }
2313
2314 #[tokio::test]
2328 async fn init_wiring_post_init_runs_as_one_ordered_segment() {
2329 #[derive(Clone)]
2330 struct TrackHooks {
2331 events: Arc<Mutex<Vec<String>>>,
2332 }
2333
2334 #[async_trait::async_trait]
2335 impl Gear for TrackHooks {
2336 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2337 self.events.lock().await.push("init".to_owned());
2338 Ok(())
2339 }
2340 }
2341
2342 #[async_trait::async_trait]
2343 impl SystemCapability for TrackHooks {
2344 fn pre_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2345 Ok(())
2346 }
2347
2348 async fn post_init(&self, _sys: &crate::runtime::SystemContext) -> anyhow::Result<()> {
2349 self.events.lock().await.push("post_init".to_owned());
2350 Ok(())
2351 }
2352 }
2353
2354 let events = Arc::new(Mutex::new(Vec::<String>::new()));
2355 let gear = Arc::new(TrackHooks {
2356 events: events.clone(),
2357 });
2358
2359 let mut builder = RegistryBuilder::default();
2360 builder.register_core_with_meta("sys", &[], gear.clone() as Arc<dyn Gear>);
2361 builder.register_system_with_meta("sys", gear.clone() as Arc<dyn SystemCapability>);
2362 let registry = builder.build_topo_sorted().unwrap();
2363
2364 let runtime = HostRuntime::new(
2365 registry,
2366 Arc::new(EmptyConfigProvider) as Arc<dyn ConfigProvider>,
2367 DbOptions::None,
2368 Arc::new(ClientHub::new()),
2369 CancellationToken::new(),
2370 Uuid::new_v4(),
2371 None,
2372 );
2373
2374 runtime.run_init_wiring_post_init().await.unwrap();
2375
2376 assert_eq!(events.lock().await.clone(), vec!["init", "post_init"]);
2377 }
2378
2379 #[tokio::test]
2380 async fn test_stop_phase_provides_fresh_deadline_token() {
2381 use std::sync::atomic::AtomicBool;
2382
2383 struct TokenCheckGear {
2384 stop_was_called: AtomicBool,
2385 token_was_cancelled_on_entry: AtomicBool,
2386 }
2387
2388 #[async_trait::async_trait]
2389 impl Gear for TokenCheckGear {
2390 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2391 Ok(())
2392 }
2393 }
2394
2395 #[async_trait::async_trait]
2396 impl RunnableCapability for TokenCheckGear {
2397 async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2398 Ok(())
2399 }
2400 async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2401 self.stop_was_called.store(true, Ordering::SeqCst);
2403 self.token_was_cancelled_on_entry
2405 .store(deadline_token.is_cancelled(), Ordering::SeqCst);
2406 Ok(())
2407 }
2408 }
2409
2410 let gear = Arc::new(TokenCheckGear {
2411 stop_was_called: AtomicBool::new(false),
2412 token_was_cancelled_on_entry: AtomicBool::new(true),
2414 });
2415
2416 let mut builder = RegistryBuilder::default();
2417 builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2418 builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2419
2420 let registry = builder.build_topo_sorted().unwrap();
2421 let client_hub = Arc::new(ClientHub::new());
2422 let cancel = CancellationToken::new();
2423 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2424
2425 let runtime = HostRuntime::new(
2426 registry,
2427 config_provider,
2428 DbOptions::None,
2429 client_hub,
2430 cancel.clone(),
2431 Uuid::new_v4(),
2432 None,
2433 );
2434
2435 runtime.run_stop_phase().await.unwrap();
2437
2438 assert!(
2440 gear.stop_was_called.load(Ordering::SeqCst),
2441 "stop() was never called - gear may not have been registered correctly"
2442 );
2443
2444 assert!(
2447 !gear.token_was_cancelled_on_entry.load(Ordering::SeqCst),
2448 "deadline_token should NOT be cancelled when stop() is called - this enables graceful shutdown"
2449 );
2450 }
2451
2452 #[tokio::test]
2453 async fn test_stop_phase_graceful_shutdown_completes_before_deadline() {
2454 use std::sync::atomic::AtomicBool;
2455 use std::time::Duration;
2456
2457 struct GracefulGear {
2458 graceful_completed: AtomicBool,
2459 deadline_fired: AtomicBool,
2460 }
2461
2462 #[async_trait::async_trait]
2463 impl Gear for GracefulGear {
2464 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2465 Ok(())
2466 }
2467 }
2468
2469 #[async_trait::async_trait]
2470 impl RunnableCapability for GracefulGear {
2471 async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2472 Ok(())
2473 }
2474 async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2475 tokio::select! {
2477 () = tokio::time::sleep(Duration::from_millis(10)) => {
2478 self.graceful_completed.store(true, Ordering::SeqCst);
2479 }
2480 () = deadline_token.cancelled() => {
2481 self.deadline_fired.store(true, Ordering::SeqCst);
2482 }
2483 }
2484 Ok(())
2485 }
2486 }
2487
2488 let gear = Arc::new(GracefulGear {
2489 graceful_completed: AtomicBool::new(false),
2490 deadline_fired: AtomicBool::new(false),
2491 });
2492
2493 let mut builder = RegistryBuilder::default();
2494 builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2495 builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2496
2497 let registry = builder.build_topo_sorted().unwrap();
2498 let client_hub = Arc::new(ClientHub::new());
2499 let cancel = CancellationToken::new();
2500 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2501
2502 let runtime = HostRuntime::new(
2504 registry,
2505 config_provider,
2506 DbOptions::None,
2507 client_hub,
2508 cancel.clone(),
2509 Uuid::new_v4(),
2510 None,
2511 )
2512 .with_shutdown_deadline(Duration::from_secs(5));
2513
2514 runtime.run_stop_phase().await.unwrap();
2515
2516 assert!(
2518 gear.graceful_completed.load(Ordering::SeqCst),
2519 "graceful shutdown should complete"
2520 );
2521 assert!(
2523 !gear.deadline_fired.load(Ordering::SeqCst),
2524 "deadline should not fire when graceful shutdown completes quickly"
2525 );
2526 }
2527
2528 #[tokio::test]
2529 async fn test_stop_phase_deadline_fires_for_slow_gear() {
2530 use std::sync::atomic::AtomicBool;
2531 use std::time::Duration;
2532
2533 struct SlowGear {
2534 graceful_completed: AtomicBool,
2535 deadline_fired: AtomicBool,
2536 }
2537
2538 #[async_trait::async_trait]
2539 impl Gear for SlowGear {
2540 async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> {
2541 Ok(())
2542 }
2543 }
2544
2545 #[async_trait::async_trait]
2546 impl RunnableCapability for SlowGear {
2547 async fn start(&self, _cancel: CancellationToken) -> anyhow::Result<()> {
2548 Ok(())
2549 }
2550 async fn stop(&self, deadline_token: CancellationToken) -> anyhow::Result<()> {
2551 tokio::select! {
2553 () = tokio::time::sleep(Duration::from_secs(10)) => {
2554 self.graceful_completed.store(true, Ordering::SeqCst);
2555 }
2556 () = deadline_token.cancelled() => {
2557 self.deadline_fired.store(true, Ordering::SeqCst);
2558 }
2559 }
2560 Ok(())
2561 }
2562 }
2563
2564 let gear = Arc::new(SlowGear {
2565 graceful_completed: AtomicBool::new(false),
2566 deadline_fired: AtomicBool::new(false),
2567 });
2568
2569 let mut builder = RegistryBuilder::default();
2570 builder.register_core_with_meta("test", &[], gear.clone() as Arc<dyn Gear>);
2571 builder.register_stateful_with_meta("test", gear.clone() as Arc<dyn RunnableCapability>);
2572
2573 let registry = builder.build_topo_sorted().unwrap();
2574 let client_hub = Arc::new(ClientHub::new());
2575 let cancel = CancellationToken::new();
2576 let config_provider: Arc<dyn ConfigProvider> = Arc::new(EmptyConfigProvider);
2577
2578 let runtime = HostRuntime::new(
2580 registry,
2581 config_provider,
2582 DbOptions::None,
2583 client_hub,
2584 cancel.clone(),
2585 Uuid::new_v4(),
2586 None,
2587 )
2588 .with_shutdown_deadline(Duration::from_millis(100));
2589
2590 runtime.run_stop_phase().await.unwrap();
2591
2592 assert!(
2594 !gear.graceful_completed.load(Ordering::SeqCst),
2595 "graceful shutdown should not complete when deadline fires first"
2596 );
2597 assert!(
2599 gear.deadline_fired.load(Ordering::SeqCst),
2600 "deadline should fire for slow gears"
2601 );
2602 }
2603}