Skip to main content

platform_admin_data/
lib.rs

1//! Schema-admin data API: generic endpoints that render any module's declared
2//! admin entities and invoke manifest-declared admin actions. Depends on NO
3//! concrete module crate: it works only through injected platform-module seams,
4//! mirroring `platform-admin`'s seam-only discipline.
5
6use platform_core::{AppError, ErrorCode, RequestContext, StoryDisplayDescriptor};
7use platform_http::{ApiErrorResponse, ApiOpenApiRouter, OpenApiRouter, routes};
8use platform_module::{
9    AdminActionSource, AdminDataSource, AdminQuerySource, AdminSchema, AdminSurface,
10    ConsoleContribution, ConsoleSlot, ConsoleSurface, EventSurface, LifecycleSurface,
11    ModuleHttpRoute, ModuleLoadStatus, ModuleSource, RuntimeSurface,
12};
13use std::sync::{Arc, OnceLock, RwLock};
14use std::time::Instant;
15
16mod dto;
17mod handlers;
18
19pub use dto::*;
20#[allow(clippy::wildcard_imports)]
21use handlers::*;
22
23/// One module's admin capability: its declared surface plus live data/action
24/// sources.
25#[derive(Clone, Debug)]
26pub struct AdminModule {
27    /// The owning module's stable name, e.g. "identity".
28    pub module_name: String,
29    /// The loading source that produced this module.
30    pub source: ModuleSource,
31    /// Current load state. The first remote slice only installs loaded modules;
32    /// error entries are reserved for degraded loading in a later slice.
33    pub load_status: ModuleLoadStatus,
34    /// The module's schema-admin surface or custom surface fallback schema.
35    pub schema: AdminSchema,
36    /// The full declared admin surface, retained for action validation.
37    pub admin: Option<AdminSurface>,
38    /// Whether this module should appear in the generic schema-admin discovery
39    /// endpoint. Declarative custom surfaces may still be readable through
40    /// fallback schema entities without being advertised as plain schema-admin.
41    pub listed_in_schema: bool,
42    /// Live read access to the module's records. Missing for degraded modules
43    /// whose manifest/data source failed to load.
44    pub data_source: Option<Arc<dyn AdminDataSource>>,
45    /// Live behavior for manifest-declared admin actions.
46    pub action_source: Option<Arc<dyn AdminActionSource>>,
47    /// Live behavior for manifest-declared read-only queries.
48    pub query_source: Option<Arc<dyn AdminQuerySource>>,
49}
50
51/// One module's registry metadata, independent of whether schema-admin
52/// list/detail reads or an admin surface are available.
53#[derive(Clone, Debug)]
54pub struct AdminModuleMetadata {
55    /// The owning module's stable name, e.g. "identity".
56    pub module_name: String,
57    /// The loading source that produced this module.
58    pub source: ModuleSource,
59    /// Current load state.
60    pub load_status: ModuleLoadStatus,
61    /// Declared module-owned HTTP routes. Metadata only; not mounted by
62    /// platform-admin-data.
63    pub http_routes: Vec<ModuleHttpRoute>,
64    /// Declared runtime functions. Metadata only; runtime registration belongs
65    /// to the source-specific binding and worker composition.
66    pub runtime: Option<RuntimeSurface>,
67    /// Declared event handlers. Metadata only; event dispatch registration
68    /// belongs to the source-specific binding and worker composition.
69    pub events: Option<EventSurface>,
70    /// Declared lifecycle checks and activation jobs. Metadata only; worker startup
71    /// owns validation and enqueueing.
72    pub lifecycle: Option<LifecycleSurface>,
73    /// Declared trusted Runtime Console frontend surfaces.
74    pub console: Vec<ConsoleSurface>,
75    /// Declared Runtime Console extension slots.
76    pub console_slots: Vec<ConsoleSlot>,
77    /// Declared Runtime Console slot contributions.
78    pub console_contributions: Vec<ConsoleContribution>,
79    /// Declared story-display mappings for runtime story titles and node
80    /// labels.
81    pub story_display: Vec<StoryDisplayDescriptor>,
82    /// Declared capability strings owned by the module.
83    pub capabilities: Vec<String>,
84    /// Declared module dependencies.
85    pub dependencies: Vec<String>,
86    /// The declared admin surface. Missing for modules with no admin surface
87    /// and degraded failed remotes whose manifest could not be loaded.
88    pub admin: Option<AdminSurface>,
89    /// Source-level diagnostics known to the host. Remote modules include
90    /// endpoint and load metadata; linked modules usually leave this empty.
91    pub source_diagnostics: Option<AdminModuleSourceDiagnostics>,
92}
93
94#[derive(Clone, Debug)]
95pub enum AdminModuleSourceDiagnostics {
96    Remote(AdminRemoteModuleDiagnostics),
97}
98
99#[derive(Clone, Debug)]
100pub struct AdminRemoteModuleDiagnostics {
101    pub transport: String,
102    pub base_url: String,
103    pub manifest_url: String,
104    pub timeout_ms: u64,
105    pub auth_configured: bool,
106    pub load_duration_ms: Option<u64>,
107    pub last_checked_at: Option<String>,
108    pub last_load_error: Option<String>,
109}
110
111#[derive(Clone, Debug, Default)]
112struct AdminModuleMetadataSnapshot {
113    modules: Vec<AdminModuleMetadata>,
114    refreshed_at: Option<String>,
115    refresh_error: Option<String>,
116    refresh_history: Vec<AdminModuleMetadataRefreshRecord>,
117}
118
119#[derive(Clone, Debug)]
120pub struct AdminModuleMetadataRefreshRecord {
121    pub id: String,
122    pub status: AdminModuleMetadataRefreshStatus,
123    pub started_at: String,
124    pub completed_at: String,
125    pub duration_ms: u64,
126    pub module_count: usize,
127    pub error: Option<String>,
128    pub module_results: Vec<AdminModuleMetadataRefreshModuleResult>,
129}
130
131#[derive(Clone, Debug)]
132pub struct AdminModuleMetadataRefreshModuleResult {
133    pub module_name: String,
134    pub source: ModuleSource,
135    pub status: AdminModuleMetadataRefreshModuleStatus,
136    pub duration_ms: Option<u64>,
137    pub endpoint: Option<String>,
138    pub error: Option<String>,
139}
140
141#[derive(Clone, Copy, Debug)]
142pub enum AdminModuleMetadataRefreshModuleStatus {
143    Loaded,
144    Error,
145}
146
147#[derive(Clone, Copy, Debug)]
148pub enum AdminModuleMetadataRefreshStatus {
149    Success,
150    Error,
151}
152
153static ADMIN_REGISTRY: OnceLock<RwLock<Vec<AdminModule>>> = OnceLock::new();
154static ADMIN_METADATA_REGISTRY: OnceLock<RwLock<AdminModuleMetadataSnapshot>> = OnceLock::new();
155static ADMIN_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleRefresher>>>> = OnceLock::new();
156static ADMIN_METADATA_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleMetadataRefresher>>>> =
157    OnceLock::new();
158
159#[async_trait::async_trait]
160pub trait AdminModuleRefresher: Send + Sync {
161    async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>>;
162}
163
164#[async_trait::async_trait]
165pub trait AdminModuleMetadataRefresher: Send + Sync {
166    async fn refresh_admin_module_metadata(
167        &self,
168    ) -> platform_core::AppResult<Vec<AdminModuleMetadata>>;
169}
170
171struct StaticAdminModuleRefresher<F>(F);
172struct StaticAdminModuleMetadataRefresher<F>(F);
173
174#[async_trait::async_trait]
175impl<F, Fut> AdminModuleRefresher for StaticAdminModuleRefresher<F>
176where
177    F: Fn() -> Fut + Send + Sync,
178    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send,
179{
180    async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>> {
181        (self.0)().await
182    }
183}
184
185#[async_trait::async_trait]
186impl<F, Fut> AdminModuleMetadataRefresher for StaticAdminModuleMetadataRefresher<F>
187where
188    F: Fn() -> Fut + Send + Sync,
189    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>> + Send,
190{
191    async fn refresh_admin_module_metadata(
192        &self,
193    ) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
194        (self.0)().await
195    }
196}
197
198/// Install the admin-capable module registry. Called once by the composition
199/// root before the router serves traffic. Later calls replace the registry,
200/// which keeps tests isolated and leaves room for explicit refresh later.
201pub fn install_admin_modules(modules: Vec<AdminModule>) {
202    let registry = ADMIN_REGISTRY.get_or_init(|| RwLock::new(Vec::new()));
203    *registry.write().expect("admin registry lock poisoned") = modules;
204}
205
206/// Install the metadata registry for every module.
207pub fn install_admin_module_metadata(modules: Vec<AdminModuleMetadata>) {
208    let registry =
209        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
210    *registry
211        .write()
212        .expect("admin metadata registry lock poisoned") = AdminModuleMetadataSnapshot {
213        modules,
214        refreshed_at: Some(current_timestamp()),
215        refresh_error: None,
216        refresh_history: Vec::new(),
217    };
218}
219
220pub(crate) fn record_admin_module_metadata_refresh_success(
221    modules: Vec<AdminModuleMetadata>,
222    started_at: String,
223    started: Instant,
224) -> AdminModuleMetadataSnapshot {
225    let registry =
226        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
227    let mut snapshot = registry
228        .write()
229        .expect("admin metadata registry lock poisoned");
230    let completed_at = current_timestamp();
231    let record = AdminModuleMetadataRefreshRecord {
232        id: format!(
233            "module_refresh_{}",
234            completed_at.replace([':', '.', '+'], "_")
235        ),
236        status: AdminModuleMetadataRefreshStatus::Success,
237        started_at,
238        completed_at: completed_at.clone(),
239        duration_ms: duration_ms(started),
240        module_count: modules.len(),
241        error: None,
242        module_results: refresh_module_results(&modules),
243    };
244    snapshot.modules = modules;
245    snapshot.refreshed_at = Some(completed_at);
246    snapshot.refresh_error = None;
247    push_refresh_record(&mut snapshot.refresh_history, record);
248    snapshot.clone()
249}
250
251/// Install the callback used by the explicit admin refresh endpoint.
252///
253/// Kept as an injected seam so this platform crate does not depend on the
254/// composition root that knows how to load linked and remote modules.
255pub fn install_admin_module_refresher(refresher: Arc<dyn AdminModuleRefresher>) {
256    let registry = ADMIN_REFRESHER.get_or_init(|| RwLock::new(None));
257    *registry.write().expect("admin refresher lock poisoned") = Some(refresher);
258}
259
260pub fn install_admin_module_refresh_fn<F, Fut>(refresh: F)
261where
262    F: Fn() -> Fut + Send + Sync + 'static,
263    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send + 'static,
264{
265    install_admin_module_refresher(Arc::new(StaticAdminModuleRefresher(refresh)));
266}
267
268/// Install the callback used to refresh module registry metadata.
269pub fn install_admin_module_metadata_refresher(refresher: Arc<dyn AdminModuleMetadataRefresher>) {
270    let registry = ADMIN_METADATA_REFRESHER.get_or_init(|| RwLock::new(None));
271    *registry
272        .write()
273        .expect("admin metadata refresher lock poisoned") = Some(refresher);
274}
275
276pub fn install_admin_module_metadata_refresh_fn<F, Fut>(refresh: F)
277where
278    F: Fn() -> Fut + Send + Sync + 'static,
279    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>>
280        + Send
281        + 'static,
282{
283    install_admin_module_metadata_refresher(Arc::new(StaticAdminModuleMetadataRefresher(refresh)));
284}
285
286fn admin_modules() -> Vec<AdminModule> {
287    ADMIN_REGISTRY
288        .get()
289        .map(|registry| {
290            registry
291                .read()
292                .expect("admin registry lock poisoned")
293                .clone()
294        })
295        .unwrap_or_default()
296}
297
298fn admin_module_metadata_snapshot() -> AdminModuleMetadataSnapshot {
299    ADMIN_METADATA_REGISTRY
300        .get()
301        .map(|registry| {
302            registry
303                .read()
304                .expect("admin metadata registry lock poisoned")
305                .clone()
306        })
307        .unwrap_or_default()
308}
309
310pub(crate) fn record_admin_module_metadata_refresh_error(
311    error: String,
312    started_at: String,
313    started: Instant,
314) -> AdminModuleMetadataSnapshot {
315    let registry =
316        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
317    let mut snapshot = registry
318        .write()
319        .expect("admin metadata registry lock poisoned");
320    snapshot.refresh_error = Some(error);
321    let completed_at = current_timestamp();
322    let record = AdminModuleMetadataRefreshRecord {
323        id: format!(
324            "module_refresh_{}",
325            completed_at.replace([':', '.', '+'], "_")
326        ),
327        status: AdminModuleMetadataRefreshStatus::Error,
328        started_at,
329        completed_at,
330        duration_ms: duration_ms(started),
331        module_count: snapshot.modules.len(),
332        error: snapshot.refresh_error.clone(),
333        module_results: Vec::new(),
334    };
335    push_refresh_record(&mut snapshot.refresh_history, record);
336    snapshot.clone()
337}
338
339pub(crate) fn current_timestamp() -> String {
340    use platform_core::Clock;
341    platform_core::SystemClock.now().to_rfc3339()
342}
343
344fn duration_ms(started: Instant) -> u64 {
345    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
346}
347
348fn push_refresh_record(
349    history: &mut Vec<AdminModuleMetadataRefreshRecord>,
350    record: AdminModuleMetadataRefreshRecord,
351) {
352    history.insert(0, record);
353    history.truncate(10);
354}
355
356fn refresh_module_results(
357    modules: &[AdminModuleMetadata],
358) -> Vec<AdminModuleMetadataRefreshModuleResult> {
359    modules
360        .iter()
361        .map(|module| {
362            let remote = match &module.source_diagnostics {
363                Some(AdminModuleSourceDiagnostics::Remote(remote)) => Some(remote),
364                None => None,
365            };
366            AdminModuleMetadataRefreshModuleResult {
367                module_name: module.module_name.clone(),
368                source: module.source,
369                status: match module.load_status {
370                    ModuleLoadStatus::Loaded => AdminModuleMetadataRefreshModuleStatus::Loaded,
371                    ModuleLoadStatus::Error { .. } => AdminModuleMetadataRefreshModuleStatus::Error,
372                },
373                duration_ms: remote.and_then(|diagnostics| diagnostics.load_duration_ms),
374                endpoint: remote.map(|diagnostics| diagnostics.base_url.clone()),
375                error: match &module.load_status {
376                    ModuleLoadStatus::Loaded => {
377                        remote.and_then(|diagnostics| diagnostics.last_load_error.clone())
378                    }
379                    ModuleLoadStatus::Error { message } => Some(message.clone()),
380                },
381            }
382        })
383        .collect()
384}
385
386fn admin_refresher() -> Option<Arc<dyn AdminModuleRefresher>> {
387    ADMIN_REFRESHER.get().and_then(|registry| {
388        registry
389            .read()
390            .expect("admin refresher lock poisoned")
391            .clone()
392    })
393}
394
395fn admin_metadata_refresher() -> Option<Arc<dyn AdminModuleMetadataRefresher>> {
396    ADMIN_METADATA_REFRESHER.get().and_then(|registry| {
397        registry
398            .read()
399            .expect("admin metadata refresher lock poisoned")
400            .clone()
401    })
402}
403
404fn find_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
405    admin_modules()
406        .into_iter()
407        .find(|m| m.module_name == module)
408        .ok_or_else(|| {
409            ApiErrorResponse::with_context(
410                AppError::new(ErrorCode::NotFound, format!("unknown module: {module}")),
411                ctx,
412            )
413        })
414}
415
416fn find_loaded_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
417    let admin_module = find_module(module, ctx)?;
418    if admin_module.data_source.is_some() {
419        Ok(admin_module)
420    } else {
421        Err(ApiErrorResponse::with_context(
422            AppError::new(
423                ErrorCode::ExternalDependency,
424                format!("module {module} is not loaded"),
425            )
426            .retryable(),
427            ctx,
428        ))
429    }
430}
431
432fn find_loaded_action_module(
433    module: &str,
434    ctx: &RequestContext,
435) -> Result<AdminModule, ApiErrorResponse> {
436    let admin_module = find_module(module, ctx)?;
437    if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
438        Ok(admin_module)
439    } else {
440        Err(ApiErrorResponse::with_context(
441            AppError::new(
442                ErrorCode::ExternalDependency,
443                format!("module {module} is not loaded"),
444            )
445            .retryable(),
446            ctx,
447        ))
448    }
449}
450
451fn find_loaded_query_module(
452    module: &str,
453    ctx: &RequestContext,
454) -> Result<AdminModule, ApiErrorResponse> {
455    let admin_module = find_module(module, ctx)?;
456    if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
457        Ok(admin_module)
458    } else {
459        Err(ApiErrorResponse::with_context(
460            AppError::new(
461                ErrorCode::ExternalDependency,
462                format!("module {module} is not loaded"),
463            )
464            .retryable(),
465            ctx,
466        ))
467    }
468}
469
470/// The schema-admin router, mounted by the API app.
471pub fn router() -> ApiOpenApiRouter {
472    OpenApiRouter::new()
473        .routes(routes!(list_modules))
474        .routes(routes!(refresh_modules))
475        .routes(routes!(available_modules))
476        .routes(routes!(service_modules))
477        .routes(routes!(service_system))
478        .routes(routes!(service_system_drift))
479        .routes(routes!(service_system_release_train))
480        .routes(routes!(service_system_runbooks))
481        .routes(routes!(launchpad))
482        .routes(routes!(launchpad_doctor))
483        .routes(routes!(launchpad_proof))
484        .routes(routes!(launchpad_change_plan))
485        .routes(routes!(install_available_module))
486        .routes(routes!(uninstall_available_module))
487        .routes(routes!(module_registry_snapshot))
488        .routes(routes!(list_schemas))
489        .routes(routes!(refresh_schemas))
490        .routes(routes!(invoke_action))
491        .routes(routes!(query_value))
492        .routes(routes!(list_records))
493        .routes(routes!(get_record))
494}