1use super::*;
8
9use boatramp_core::function::FunctionSummary;
10
11#[derive(serde::Deserialize)]
13pub(super) struct FunctionQuery {
14 site: Option<String>,
15}
16
17pub(super) async fn list_functions(
23 State(deploy): State<DeployStore>,
24 Extension(project): axum::extract::Extension<ProjectContext>,
25 axum::extract::Query(query): axum::extract::Query<FunctionQuery>,
26) -> Response {
27 use boatramp_core::function;
28 let sites = match &query.site {
29 Some(s) => vec![s.clone()],
30 None => match deploy.all_sites(project.as_ref()).await {
31 Ok(s) => s,
32 Err(err) => return deploy_error_response(err),
33 },
34 };
35 let mut out: Vec<FunctionSummary> = Vec::new();
36 for site in sites {
37 let manifest = match deploy.current_manifest(project.as_ref(), &site).await {
38 Ok(Some(m)) => m,
39 Ok(None) => continue,
40 Err(err) => return deploy_error_response(err),
41 };
42 let (specs, triggers) = function::desugar(&manifest.config);
43 for f in function::materialize(&specs, &site, &manifest.files, 0) {
44 let trigs = triggers
45 .iter()
46 .filter(|t| t.target.as_ref().map(|r| r.name.as_str()) == Some(f.name.as_str()))
47 .map(std::string::ToString::to_string)
48 .collect();
49 out.push(FunctionSummary {
50 name: format!("{site}/{}", f.name),
51 owner: format!("site:{site}"),
52 runtime: f.config.runtime.as_str().to_string(),
53 version: f.active,
54 triggers: trigs,
55 });
56 }
57 }
58 if query.site.is_none() {
61 match deploy.list_stored_functions(project.as_ref()).await {
62 Ok(stored) => {
63 for f in stored {
64 out.push(FunctionSummary {
65 name: f.name.clone(),
66 owner: f.owner.to_string(),
67 runtime: f.config.runtime.as_str().to_string(),
68 version: f.active,
69 triggers: vec![format!("invoke {}", f.name)],
71 });
72 }
73 }
74 Err(err) => return deploy_error_response(err),
75 }
76 }
77 Json(out).into_response()
78}
79
80#[derive(serde::Deserialize)]
82pub(super) struct FunctionUpsert {
83 pub(super) component: String,
85 #[serde(default)]
87 pub(super) config: boatramp_core::function::FunctionConfig,
88 #[serde(default)]
91 pub(super) lifecycle: boatramp_core::function::Lifecycle,
92}
93
94#[derive(serde::Deserialize, Default)]
96pub(super) struct DeployFunctionQuery {
97 #[serde(default)]
102 register_subgraph: Option<bool>,
103}
104
105#[cfg(feature = "handlers")]
109fn scan_manifest_sections(bytes: &[u8], f: &mut impl FnMut(&[u8])) {
110 use wasmparser::{Parser, Payload};
111 for payload in Parser::new(0).parse_all(bytes) {
112 match payload {
113 Ok(Payload::CustomSection(reader)) if reader.name() == "boatramp:function-manifest" => {
114 f(reader.data());
115 }
116 Ok(Payload::ModuleSection {
117 unchecked_range, ..
118 }) => scan_manifest_sections(&bytes[unchecked_range], f),
119 Ok(_) => {}
120 Err(_) => return,
121 }
122 }
123}
124
125#[cfg(feature = "handlers")]
130fn component_declares_subgraph(component: &[u8]) -> bool {
131 let mut declared = false;
132 scan_manifest_sections(component, &mut |data| {
133 for line in data.split(|&b| b == b'\n') {
134 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
135 if v.get("subgraph").and_then(serde_json::Value::as_bool) == Some(true) {
136 declared = true;
137 }
138 }
139 }
140 });
141 declared
142}
143
144#[cfg(feature = "handlers")]
150pub(crate) fn component_declares_streaming_route(component: &[u8], route: &str) -> bool {
151 let mut declared = false;
152 scan_manifest_sections(component, &mut |data| {
153 for line in data.split(|&b| b == b'\n') {
154 let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) else {
155 continue;
156 };
157 if v.get("streaming").and_then(serde_json::Value::as_bool) != Some(true) {
158 continue;
159 }
160 let Some(triggers) = v.get("triggers").and_then(serde_json::Value::as_array) else {
162 continue;
163 };
164 for t in triggers {
165 if t.get("on").and_then(serde_json::Value::as_str) != Some("http") {
166 continue;
167 }
168 if let Some(r) = t.get("route").and_then(serde_json::Value::as_str) {
169 let path = r.rsplit(char::is_whitespace).next().unwrap_or(r);
171 if path == route {
172 declared = true;
173 }
174 }
175 }
176 }
177 });
178 declared
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
187#[serde(rename_all = "lowercase")]
188pub enum Lifecycle {
189 Stable,
190 Experimental,
191}
192
193impl Lifecycle {
194 #[must_use]
196 pub fn as_str(self) -> &'static str {
197 match self {
198 Self::Stable => "stable",
199 Self::Experimental => "experimental",
200 }
201 }
202}
203
204#[derive(Debug, Clone, serde::Serialize)]
208pub struct CapabilityFeature {
209 pub name: &'static str,
210 pub lifecycle: Lifecycle,
211}
212
213#[cfg(feature = "handlers")]
220pub fn host_capability_features_detailed() -> Vec<CapabilityFeature> {
221 use Lifecycle::{Experimental, Stable};
222 let mut f: Vec<CapabilityFeature> = boatramp_core::config::known_imports()
224 .iter()
225 .map(|&name| CapabilityFeature {
226 name,
227 lifecycle: Stable,
228 })
229 .collect();
230 f.push(CapabilityFeature {
232 name: "sql-json",
233 lifecycle: Stable,
234 });
235 f.push(CapabilityFeature {
238 name: "orm-vector",
239 lifecycle: Experimental,
240 });
241 f.push(CapabilityFeature {
242 name: "streaming",
243 lifecycle: Stable,
244 });
245 if cfg!(feature = "orm-subquery") {
246 f.push(CapabilityFeature {
249 name: "orm-subquery",
250 lifecycle: Experimental,
251 });
252 }
253 f
254}
255
256#[cfg(feature = "handlers")]
259pub fn host_capability_features() -> Vec<&'static str> {
260 host_capability_features_detailed()
261 .into_iter()
262 .map(|c| c.name)
263 .collect()
264}
265
266#[cfg(feature = "handlers")]
270pub fn component_requires(component: &[u8]) -> Vec<String> {
271 let mut reqs = Vec::new();
272 scan_manifest_sections(component, &mut |data| {
273 for line in data.split(|&b| b == b'\n') {
274 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
275 if let Some(arr) = v.get("requires").and_then(serde_json::Value::as_array) {
276 reqs.extend(arr.iter().filter_map(|r| r.as_str().map(str::to_string)));
277 }
278 }
279 }
280 });
281 reqs.sort();
282 reqs.dedup();
283 reqs
284}
285
286#[cfg(feature = "handlers")]
289pub fn unmet_requires(component: &[u8]) -> Vec<String> {
290 let host = host_capability_features();
291 component_requires(component)
292 .into_iter()
293 .filter(|r| !host.contains(&r.as_str()))
294 .collect()
295}
296
297#[cfg(feature = "handlers")]
306async fn maybe_register_subgraph(
307 deploy: &DeployStore,
308 handlers: &HandlerRuntime,
309 project: boatramp_core::project::ProjectRef<'_>,
310 name: &str,
311 function: &boatramp_core::function::Function,
312 component: &str,
313 register: Option<bool>,
314) -> Result<(), Response> {
315 if register == Some(false) {
316 return Ok(()); }
318 let kv = deploy.kv().as_ref();
319 if !crate::graphql_registry::is_registered_subgraph(kv, project.as_str(), name).await {
320 match crate::handler_dispatch::read_blob_fully(deploy, component).await {
323 Ok(blob) if component_declares_subgraph(&blob) => {}
324 _ => return Ok(()),
325 }
326 }
327 let sdl = match handlers
328 .introspect_subgraph_sdl(deploy, project, function, component)
329 .await
330 {
331 Ok(sdl) => sdl,
332 Err(crate::function_runtime::SubgraphSdlError::Unavailable) => return Ok(()),
334 Err(crate::function_runtime::SubgraphSdlError::NotASubgraph) => {
335 return Err((
336 StatusCode::UNPROCESSABLE_ENTITY,
337 format!(
338 "subgraph `{name}` does not answer `{{ _service {{ sdl }} }}`; deploy with \
339 `?register_subgraph=false` to skip subgraph registration\n"
340 ),
341 )
342 .into_response())
343 }
344 Err(crate::function_runtime::SubgraphSdlError::InvokeFailed(msg)) => {
345 return Err((
346 StatusCode::BAD_GATEWAY,
347 format!("could not introspect subgraph `{name}`: {msg}\n"),
348 )
349 .into_response())
350 }
351 };
352 match crate::graphql_registry::publish(kv, project.as_str(), name, &sdl).await {
353 Ok(_) => Ok(()),
354 Err(crate::graphql_registry::PublishError::Composition(e)) => Err((
355 StatusCode::BAD_REQUEST,
356 format!(
357 "subgraph `{name}` does not compose: {e}\n(deploy with `?register_subgraph=false` \
358 to skip, or unregister a conflicting subgraph first)\n"
359 ),
360 )
361 .into_response()),
362 Err(crate::graphql_registry::PublishError::Store(e)) => Err((
363 StatusCode::INTERNAL_SERVER_ERROR,
364 format!("registry store error: {e}\n"),
365 )
366 .into_response()),
367 }
368}
369
370#[cfg(not(feature = "handlers"))]
372async fn maybe_register_subgraph(
373 _deploy: &DeployStore,
374 _handlers: &HandlerRuntime,
375 _project: boatramp_core::project::ProjectRef<'_>,
376 _name: &str,
377 _function: &boatramp_core::function::Function,
378 _component: &str,
379 _register: Option<bool>,
380) -> Result<(), Response> {
381 Ok(())
382}
383
384pub(super) async fn deploy_function(
389 State(deploy): State<DeployStore>,
390 Extension(project): axum::extract::Extension<ProjectContext>,
391 Extension(handlers): Extension<Arc<HandlerRuntime>>,
392 axum::extract::Query(q): axum::extract::Query<DeployFunctionQuery>,
393 Path(name): Path<String>,
394 Json(body): Json<FunctionUpsert>,
395) -> Response {
396 use boatramp_core::function::{Function, Owner};
397 if let Some(resp) = reject_invalid_name("function", &name) {
398 return resp;
399 }
400 #[cfg(feature = "handlers")]
408 if let Err(err) = crate::handler_dispatch::admit_secret_refs(
409 &body.config.secrets,
410 handlers.allow_env_secret_refs(),
411 ) {
412 return (
413 StatusCode::BAD_REQUEST,
414 format!("function secrets: {err}\n"),
415 )
416 .into_response();
417 }
418 match deploy.has_blob(&body.component).await {
419 Ok(true) => {}
420 Ok(false) => {
421 return (
422 StatusCode::BAD_REQUEST,
423 format!("component blob {} not uploaded\n", body.component),
424 )
425 .into_response()
426 }
427 Err(err) => return deploy_error_response(err),
428 }
429 let now = now_unix();
430 let f = match deploy.get_function(project.as_ref(), &name).await {
431 Ok(Some(mut existing)) => {
432 existing.config = body.config;
433 existing.upsert_version(&body.component, body.lifecycle, now);
434 existing
435 }
436 Ok(None) => Function::new(
439 name.clone(),
440 Owner::Project("default".to_string()),
441 &body.component,
442 body.config,
443 body.lifecycle,
444 now,
445 ),
446 Err(err) => return deploy_error_response(err),
447 };
448 if let Err(resp) = maybe_register_subgraph(
452 &deploy,
453 &handlers,
454 project.as_ref(),
455 &name,
456 &f,
457 &body.component,
458 q.register_subgraph,
459 )
460 .await
461 {
462 return resp;
463 }
464 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
465 return deploy_error_response(err);
466 }
467 Json(f).into_response()
468}
469
470#[derive(serde::Deserialize)]
472pub(super) struct RollbackBody {
473 pub(super) to: String,
474}
475
476pub(super) async fn rollback_function(
478 State(deploy): State<DeployStore>,
479 Extension(project): axum::extract::Extension<ProjectContext>,
480 Path(name): Path<String>,
481 Json(body): Json<RollbackBody>,
482) -> Response {
483 match deploy.get_function(project.as_ref(), &name).await {
484 Ok(Some(mut f)) => match f.rollback(&body.to) {
485 Ok(()) => {
486 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
487 return deploy_error_response(err);
488 }
489 Json(f).into_response()
490 }
491 Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
492 },
493 Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
494 Err(err) => deploy_error_response(err),
495 }
496}
497
498#[derive(serde::Deserialize)]
500pub(super) struct AliasBody {
501 pub(super) version: String,
502}
503
504pub(super) async fn alias_function(
506 State(deploy): State<DeployStore>,
507 Extension(project): axum::extract::Extension<ProjectContext>,
508 Path((name, label)): Path<(String, String)>,
509 Json(body): Json<AliasBody>,
510) -> Response {
511 match deploy.get_function(project.as_ref(), &name).await {
512 Ok(Some(mut f)) => match f.set_alias(&label, &body.version) {
513 Ok(()) => {
514 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
515 return deploy_error_response(err);
516 }
517 Json(f).into_response()
518 }
519 Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
520 },
521 Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
522 Err(err) => deploy_error_response(err),
523 }
524}
525
526pub(super) async fn remove_function(
529 State(deploy): State<DeployStore>,
530 Extension(project): axum::extract::Extension<ProjectContext>,
531 Path(name): Path<String>,
532) -> Response {
533 match deploy.delete_function(project.as_ref(), &name).await {
534 Ok(_) => StatusCode::NO_CONTENT.into_response(),
535 Err(err) => deploy_error_response(err),
536 }
537}
538
539#[cfg(all(test, feature = "handlers"))]
540mod tests {
541 use super::*;
542 use boatramp_core::function::{Function, FunctionConfig, Lifecycle, Owner};
543 use boatramp_core::kv::MemoryKv;
544 use std::sync::Arc;
545
546 #[test]
547 fn host_capability_features_registry_and_requires_filter() {
548 let host = host_capability_features();
549 assert!(host.contains(&"sql"), "base capability token present");
551 assert!(
552 host.contains(&"streaming"),
553 "always-on surface feature present"
554 );
555 assert!(host.contains(&"orm-vector"));
556 assert_eq!(
558 host.contains(&"orm-subquery"),
559 cfg!(feature = "orm-subquery")
560 );
561 let detailed = host_capability_features_detailed();
564 let detailed_names: Vec<&str> = detailed.iter().map(|c| c.name).collect();
565 assert_eq!(
566 detailed_names, host,
567 "detailed names match the flat registry"
568 );
569 let lifecycle = |name: &str| {
570 detailed
571 .iter()
572 .find(|c| c.name == name)
573 .map(|c| c.lifecycle)
574 };
575 assert_eq!(lifecycle("sql"), Some(super::Lifecycle::Stable));
576 assert_eq!(lifecycle("sql-json"), Some(super::Lifecycle::Stable));
577 assert_eq!(
578 lifecycle("orm-vector"),
579 Some(super::Lifecycle::Experimental)
580 );
581 let unmet: Vec<&str> = ["sql", "streaming", "quantum-teleport", "warp-drive"]
583 .into_iter()
584 .filter(|r| !host.contains(r))
585 .collect();
586 assert_eq!(unmet, vec!["quantum-teleport", "warp-drive"]);
587 }
588
589 struct NullStorage;
591 #[async_trait::async_trait]
592 impl boatramp_core::Storage for NullStorage {
593 async fn get(
594 &self,
595 _: &str,
596 ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
597 Err(boatramp_core::StorageError::NotFound(String::new()))
598 }
599 async fn get_range(
600 &self,
601 _: &str,
602 _: u64,
603 _: Option<u64>,
604 ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
605 Err(boatramp_core::StorageError::NotFound(String::new()))
606 }
607 async fn put(
608 &self,
609 _: &str,
610 _: boatramp_core::ByteStream,
611 _: boatramp_core::PutMeta,
612 ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
613 Err(boatramp_core::StorageError::unsupported("null"))
614 }
615 async fn head(
616 &self,
617 _: &str,
618 ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
619 Err(boatramp_core::StorageError::NotFound(String::new()))
620 }
621 async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
622 Ok(())
623 }
624 async fn list(
625 &self,
626 _: &str,
627 ) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
628 Ok(Vec::new())
629 }
630 }
631
632 fn a_function() -> Function {
633 Function::new(
634 "accounts",
635 Owner::Project("default".to_string()),
636 "component-hash",
637 FunctionConfig::default(),
638 Lifecycle::default(),
639 0,
640 )
641 }
642
643 #[tokio::test]
647 async fn refresh_is_a_noop_unless_the_function_is_a_registered_subgraph() {
648 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
649 let handlers = HandlerRuntime::disabled();
650 let project = boatramp_core::project::ProjectRef::new("default");
651 let f = a_function();
652
653 maybe_register_subgraph(
655 &deploy,
656 &handlers,
657 project,
658 "accounts",
659 &f,
660 "component-hash",
661 None,
662 )
663 .await
664 .expect("an unregistered function deploys freely");
665 assert!(
666 !crate::graphql_registry::is_registered_subgraph(
667 deploy.kv().as_ref(),
668 "default",
669 "accounts"
670 )
671 .await
672 );
673
674 crate::graphql_registry::publish(
676 deploy.kv().as_ref(),
677 "default",
678 "accounts",
679 "type Query { x: Int }",
680 )
681 .await
682 .unwrap();
683 maybe_register_subgraph(
684 &deploy,
685 &handlers,
686 project,
687 "accounts",
688 &f,
689 "component-hash",
690 Some(false),
691 )
692 .await
693 .expect("opt-out never blocks");
694
695 maybe_register_subgraph(
698 &deploy,
699 &handlers,
700 project,
701 "accounts",
702 &f,
703 "component-hash",
704 None,
705 )
706 .await
707 .expect("a node with no engine skips the refresh, it does not block");
708 }
709
710 fn leb128(mut n: usize, out: &mut Vec<u8>) {
714 loop {
715 let mut byte = (n & 0x7f) as u8;
716 n >>= 7;
717 if n != 0 {
718 byte |= 0x80;
719 }
720 out.push(byte);
721 if n == 0 {
722 break;
723 }
724 }
725 }
726
727 fn module_with_manifest(manifest: &[u8]) -> Vec<u8> {
728 let name = b"boatramp:function-manifest";
729 let mut payload = Vec::new();
730 leb128(name.len(), &mut payload);
731 payload.extend_from_slice(name);
732 payload.extend_from_slice(manifest);
733 let mut module = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; module.push(0x00); leb128(payload.len(), &mut module);
736 module.extend_from_slice(&payload);
737 module
738 }
739
740 #[test]
741 fn a_subgraph_marker_in_the_manifest_is_detected() {
742 assert!(component_declares_subgraph(&module_with_manifest(
743 br#"{"name":"schema","triggers":[{"on":"http","route":"POST /graphql"}],"authorize":"public","subgraph":true}"#
744 )));
745 assert!(!component_declares_subgraph(&module_with_manifest(
747 br#"{"name":"orders","authorize":"tenant"}"#
748 )));
749 assert!(!component_declares_subgraph(&[
751 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00
752 ]));
753 assert!(!component_declares_subgraph(b"not a wasm module"));
755 }
756}