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 {
244 name: "orm-own-pref",
245 lifecycle: Experimental,
246 });
247 f.push(CapabilityFeature {
248 name: "streaming",
249 lifecycle: Stable,
250 });
251 if cfg!(feature = "session") {
255 f.push(CapabilityFeature {
256 name: "session",
257 lifecycle: Experimental,
258 });
259 }
260 if cfg!(feature = "handlers") {
265 f.push(CapabilityFeature {
266 name: "tenancy",
267 lifecycle: Experimental,
268 });
269 }
270 if cfg!(feature = "orm-subquery") {
271 f.push(CapabilityFeature {
274 name: "orm-subquery",
275 lifecycle: Experimental,
276 });
277 }
278 f
279}
280
281#[cfg(feature = "handlers")]
284pub fn host_capability_features() -> Vec<&'static str> {
285 host_capability_features_detailed()
286 .into_iter()
287 .map(|c| c.name)
288 .collect()
289}
290
291#[cfg(feature = "handlers")]
295pub fn component_requires(component: &[u8]) -> Vec<String> {
296 let mut reqs = Vec::new();
297 scan_manifest_sections(component, &mut |data| {
298 for line in data.split(|&b| b == b'\n') {
299 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
300 if let Some(arr) = v.get("requires").and_then(serde_json::Value::as_array) {
301 reqs.extend(arr.iter().filter_map(|r| r.as_str().map(str::to_string)));
302 }
303 }
304 }
305 });
306 reqs.sort();
307 reqs.dedup();
308 reqs
309}
310
311#[cfg(feature = "handlers")]
314pub fn unmet_requires(component: &[u8]) -> Vec<String> {
315 let host = host_capability_features();
316 component_requires(component)
317 .into_iter()
318 .filter(|r| !host.contains(&r.as_str()))
319 .collect()
320}
321
322#[cfg(feature = "handlers")]
331async fn maybe_register_subgraph(
332 deploy: &DeployStore,
333 handlers: &HandlerRuntime,
334 project: boatramp_core::project::ProjectRef<'_>,
335 name: &str,
336 function: &boatramp_core::function::Function,
337 component: &str,
338 register: Option<bool>,
339) -> Result<(), Response> {
340 if register == Some(false) {
341 return Ok(()); }
343 let kv = deploy.kv().as_ref();
344 if !crate::graphql_registry::is_registered_subgraph(kv, project.as_str(), name).await {
345 match crate::handler_dispatch::read_blob_fully(deploy, component).await {
348 Ok(blob) if component_declares_subgraph(&blob) => {}
349 _ => return Ok(()),
350 }
351 }
352 let sdl = match handlers
353 .introspect_subgraph_sdl(deploy, project, function, component)
354 .await
355 {
356 Ok(sdl) => sdl,
357 Err(crate::function_runtime::SubgraphSdlError::Unavailable) => return Ok(()),
359 Err(crate::function_runtime::SubgraphSdlError::NotASubgraph) => {
360 return Err((
361 StatusCode::UNPROCESSABLE_ENTITY,
362 format!(
363 "subgraph `{name}` does not answer `{{ _service {{ sdl }} }}`; deploy with \
364 `?register_subgraph=false` to skip subgraph registration\n"
365 ),
366 )
367 .into_response())
368 }
369 Err(crate::function_runtime::SubgraphSdlError::InvokeFailed(msg)) => {
370 return Err((
371 StatusCode::BAD_GATEWAY,
372 format!("could not introspect subgraph `{name}`: {msg}\n"),
373 )
374 .into_response())
375 }
376 };
377 match crate::graphql_registry::publish(kv, project.as_str(), name, &sdl).await {
378 Ok(_) => Ok(()),
379 Err(crate::graphql_registry::PublishError::Composition(e)) => Err((
380 StatusCode::BAD_REQUEST,
381 format!(
382 "subgraph `{name}` does not compose: {e}\n(deploy with `?register_subgraph=false` \
383 to skip, or unregister a conflicting subgraph first)\n"
384 ),
385 )
386 .into_response()),
387 Err(crate::graphql_registry::PublishError::Store(e)) => Err((
388 StatusCode::INTERNAL_SERVER_ERROR,
389 format!("registry store error: {e}\n"),
390 )
391 .into_response()),
392 }
393}
394
395#[cfg(not(feature = "handlers"))]
397async fn maybe_register_subgraph(
398 _deploy: &DeployStore,
399 _handlers: &HandlerRuntime,
400 _project: boatramp_core::project::ProjectRef<'_>,
401 _name: &str,
402 _function: &boatramp_core::function::Function,
403 _component: &str,
404 _register: Option<bool>,
405) -> Result<(), Response> {
406 Ok(())
407}
408
409pub(super) async fn deploy_function(
414 State(deploy): State<DeployStore>,
415 Extension(project): axum::extract::Extension<ProjectContext>,
416 Extension(handlers): Extension<Arc<HandlerRuntime>>,
417 axum::extract::Query(q): axum::extract::Query<DeployFunctionQuery>,
418 Path(name): Path<String>,
419 Json(body): Json<FunctionUpsert>,
420) -> Response {
421 use boatramp_core::function::{Function, Owner};
422 if let Some(resp) = reject_invalid_name("function", &name) {
423 return resp;
424 }
425 #[cfg(feature = "handlers")]
433 if let Err(err) = crate::handler_dispatch::admit_secret_refs(
434 &body.config.secrets,
435 handlers.allow_env_secret_refs(),
436 ) {
437 return (
438 StatusCode::BAD_REQUEST,
439 format!("function secrets: {err}\n"),
440 )
441 .into_response();
442 }
443 match deploy.has_blob(&body.component).await {
444 Ok(true) => {}
445 Ok(false) => {
446 return (
447 StatusCode::BAD_REQUEST,
448 format!("component blob {} not uploaded\n", body.component),
449 )
450 .into_response()
451 }
452 Err(err) => return deploy_error_response(err),
453 }
454 let now = now_unix();
455 let f = match deploy.get_function(project.as_ref(), &name).await {
456 Ok(Some(mut existing)) => {
457 existing.config = body.config;
458 existing.upsert_version(&body.component, body.lifecycle, now);
459 existing
460 }
461 Ok(None) => Function::new(
464 name.clone(),
465 Owner::Project("default".to_string()),
466 &body.component,
467 body.config,
468 body.lifecycle,
469 now,
470 ),
471 Err(err) => return deploy_error_response(err),
472 };
473 if let Err(resp) = maybe_register_subgraph(
477 &deploy,
478 &handlers,
479 project.as_ref(),
480 &name,
481 &f,
482 &body.component,
483 q.register_subgraph,
484 )
485 .await
486 {
487 return resp;
488 }
489 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
490 return deploy_error_response(err);
491 }
492 Json(f).into_response()
493}
494
495#[derive(serde::Deserialize)]
497pub(super) struct RollbackBody {
498 pub(super) to: String,
499}
500
501pub(super) async fn rollback_function(
503 State(deploy): State<DeployStore>,
504 Extension(project): axum::extract::Extension<ProjectContext>,
505 Path(name): Path<String>,
506 Json(body): Json<RollbackBody>,
507) -> Response {
508 match deploy.get_function(project.as_ref(), &name).await {
509 Ok(Some(mut f)) => match f.rollback(&body.to) {
510 Ok(()) => {
511 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
512 return deploy_error_response(err);
513 }
514 Json(f).into_response()
515 }
516 Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
517 },
518 Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
519 Err(err) => deploy_error_response(err),
520 }
521}
522
523#[derive(serde::Deserialize)]
525pub(super) struct AliasBody {
526 pub(super) version: String,
527}
528
529pub(super) async fn alias_function(
531 State(deploy): State<DeployStore>,
532 Extension(project): axum::extract::Extension<ProjectContext>,
533 Path((name, label)): Path<(String, String)>,
534 Json(body): Json<AliasBody>,
535) -> Response {
536 match deploy.get_function(project.as_ref(), &name).await {
537 Ok(Some(mut f)) => match f.set_alias(&label, &body.version) {
538 Ok(()) => {
539 if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
540 return deploy_error_response(err);
541 }
542 Json(f).into_response()
543 }
544 Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
545 },
546 Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
547 Err(err) => deploy_error_response(err),
548 }
549}
550
551pub(super) async fn remove_function(
554 State(deploy): State<DeployStore>,
555 Extension(project): axum::extract::Extension<ProjectContext>,
556 Path(name): Path<String>,
557) -> Response {
558 match deploy.delete_function(project.as_ref(), &name).await {
559 Ok(_) => StatusCode::NO_CONTENT.into_response(),
560 Err(err) => deploy_error_response(err),
561 }
562}
563
564#[cfg(all(test, feature = "handlers"))]
565mod tests {
566 use super::*;
567 use boatramp_core::function::{Function, FunctionConfig, Lifecycle, Owner};
568 use boatramp_core::kv::MemoryKv;
569 use std::sync::Arc;
570
571 #[test]
572 fn host_capability_features_registry_and_requires_filter() {
573 let host = host_capability_features();
574 assert!(host.contains(&"sql"), "base capability token present");
576 assert!(
577 host.contains(&"streaming"),
578 "always-on surface feature present"
579 );
580 assert!(host.contains(&"orm-vector"));
581 assert!(host.contains(&"orm-own-pref"));
582 assert_eq!(
584 host.contains(&"orm-subquery"),
585 cfg!(feature = "orm-subquery")
586 );
587 assert_eq!(host.contains(&"session"), cfg!(feature = "session"));
589 let detailed = host_capability_features_detailed();
592 let detailed_names: Vec<&str> = detailed.iter().map(|c| c.name).collect();
593 assert_eq!(
594 detailed_names, host,
595 "detailed names match the flat registry"
596 );
597 let lifecycle = |name: &str| {
598 detailed
599 .iter()
600 .find(|c| c.name == name)
601 .map(|c| c.lifecycle)
602 };
603 assert_eq!(lifecycle("sql"), Some(super::Lifecycle::Stable));
604 assert_eq!(lifecycle("sql-json"), Some(super::Lifecycle::Stable));
605 assert_eq!(
606 lifecycle("orm-vector"),
607 Some(super::Lifecycle::Experimental)
608 );
609 assert_eq!(
610 lifecycle("orm-own-pref"),
611 Some(super::Lifecycle::Experimental)
612 );
613 let unmet: Vec<&str> = ["sql", "streaming", "quantum-teleport", "warp-drive"]
615 .into_iter()
616 .filter(|r| !host.contains(r))
617 .collect();
618 assert_eq!(unmet, vec!["quantum-teleport", "warp-drive"]);
619 }
620
621 struct NullStorage;
623 #[async_trait::async_trait]
624 impl boatramp_core::Storage for NullStorage {
625 async fn get(
626 &self,
627 _: &str,
628 ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
629 Err(boatramp_core::StorageError::NotFound(String::new()))
630 }
631 async fn get_range(
632 &self,
633 _: &str,
634 _: u64,
635 _: Option<u64>,
636 ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
637 Err(boatramp_core::StorageError::NotFound(String::new()))
638 }
639 async fn put(
640 &self,
641 _: &str,
642 _: boatramp_core::ByteStream,
643 _: boatramp_core::PutMeta,
644 ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
645 Err(boatramp_core::StorageError::unsupported("null"))
646 }
647 async fn head(
648 &self,
649 _: &str,
650 ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
651 Err(boatramp_core::StorageError::NotFound(String::new()))
652 }
653 async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
654 Ok(())
655 }
656 async fn list(
657 &self,
658 _: &str,
659 ) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
660 Ok(Vec::new())
661 }
662 }
663
664 fn a_function() -> Function {
665 Function::new(
666 "accounts",
667 Owner::Project("default".to_string()),
668 "component-hash",
669 FunctionConfig::default(),
670 Lifecycle::default(),
671 0,
672 )
673 }
674
675 #[tokio::test]
679 async fn refresh_is_a_noop_unless_the_function_is_a_registered_subgraph() {
680 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
681 let handlers = HandlerRuntime::disabled();
682 let project = boatramp_core::project::ProjectRef::new("default");
683 let f = a_function();
684
685 maybe_register_subgraph(
687 &deploy,
688 &handlers,
689 project,
690 "accounts",
691 &f,
692 "component-hash",
693 None,
694 )
695 .await
696 .expect("an unregistered function deploys freely");
697 assert!(
698 !crate::graphql_registry::is_registered_subgraph(
699 deploy.kv().as_ref(),
700 "default",
701 "accounts"
702 )
703 .await
704 );
705
706 crate::graphql_registry::publish(
708 deploy.kv().as_ref(),
709 "default",
710 "accounts",
711 "type Query { x: Int }",
712 )
713 .await
714 .unwrap();
715 maybe_register_subgraph(
716 &deploy,
717 &handlers,
718 project,
719 "accounts",
720 &f,
721 "component-hash",
722 Some(false),
723 )
724 .await
725 .expect("opt-out never blocks");
726
727 maybe_register_subgraph(
730 &deploy,
731 &handlers,
732 project,
733 "accounts",
734 &f,
735 "component-hash",
736 None,
737 )
738 .await
739 .expect("a node with no engine skips the refresh, it does not block");
740 }
741
742 fn leb128(mut n: usize, out: &mut Vec<u8>) {
746 loop {
747 let mut byte = (n & 0x7f) as u8;
748 n >>= 7;
749 if n != 0 {
750 byte |= 0x80;
751 }
752 out.push(byte);
753 if n == 0 {
754 break;
755 }
756 }
757 }
758
759 fn module_with_manifest(manifest: &[u8]) -> Vec<u8> {
760 let name = b"boatramp:function-manifest";
761 let mut payload = Vec::new();
762 leb128(name.len(), &mut payload);
763 payload.extend_from_slice(name);
764 payload.extend_from_slice(manifest);
765 let mut module = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; module.push(0x00); leb128(payload.len(), &mut module);
768 module.extend_from_slice(&payload);
769 module
770 }
771
772 #[test]
773 fn a_subgraph_marker_in_the_manifest_is_detected() {
774 assert!(component_declares_subgraph(&module_with_manifest(
775 br#"{"name":"schema","triggers":[{"on":"http","route":"POST /graphql"}],"authorize":"public","subgraph":true}"#
776 )));
777 assert!(!component_declares_subgraph(&module_with_manifest(
779 br#"{"name":"orders","authorize":"tenant"}"#
780 )));
781 assert!(!component_declares_subgraph(&[
783 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00
784 ]));
785 assert!(!component_declares_subgraph(b"not a wasm module"));
787 }
788}