1use std::collections::BTreeMap;
54use std::path::PathBuf;
55
56use anyhow::{bail, Context, Result};
57use async_trait::async_trait;
58
59use workload_spec::{
60 BlakeHash, BundleLifecycle, MesofactRevalidateReceiver, MesofactServeBundle, Millis,
61};
62
63use super::{ReconcileCtx, Reconciler, RunningWorkload};
64use crate::config::CloudConfig;
65use crate::MirrorConfig;
66
67pub const SLOT_ROLE: &str = "bundle";
69
70pub const REVALIDATE_KEY: &str = "revalidate";
73
74pub const DEFAULT_IDLE_TTL_MS: u64 = 300_000;
77
78pub fn slot_declared(mirror: &MirrorConfig) -> bool {
84 mirror.providers.contains_key(SLOT_ROLE)
85}
86
87pub fn resolve_slot_path(workspace_root: &std::path::Path, path: &std::path::Path) -> PathBuf {
92 if path.is_absolute() {
93 path.to_path_buf()
94 } else {
95 workspace_root.join(path)
96 }
97}
98
99pub fn missing_bins(slot: &BundleSlot, workspace_root: &std::path::Path) -> Vec<(String, PathBuf)> {
104 let serve = slot
105 .serve_bins
106 .iter()
107 .map(|(triple, path)| (format!("providers.{SLOT_ROLE}.serve_bins.{triple}"), path));
108 let feed = slot.revalidate.iter().flat_map(|rv| {
109 rv.feed_bins.iter().map(|(triple, path)| {
110 (
111 format!("providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple}"),
112 path,
113 )
114 })
115 });
116 serve
117 .chain(feed)
118 .filter_map(|(label, path)| {
119 let resolved = resolve_slot_path(workspace_root, path);
120 (!resolved.is_file()).then_some((label, resolved))
121 })
122 .collect()
123}
124
125pub fn slot_ready(slot: &BundleSlot, workspace_root: &std::path::Path) -> bool {
145 missing_bins(slot, workspace_root).is_empty()
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BundleSlot {
167 pub bucket: String,
169 pub account: Option<String>,
172 pub name: Option<String>,
176 pub machines: Vec<String>,
179 pub runtime_version: Option<String>,
183 pub serve_bins: BTreeMap<String, PathBuf>,
186 pub lifecycle: BundleLifecycle,
188 pub port: Option<u16>,
192 pub revalidate: Option<RevalidateSlot>,
195 pub zone: Option<String>,
203 pub verify_serving: bool,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct RevalidateSlot {
227 pub routes: Vec<String>,
230 pub mirror_key_env: Option<String>,
234 pub publish_config: Option<PathBuf>,
237 pub feeds: Vec<String>,
241 pub feed_interval_secs: Option<u64>,
243 pub feed_bins: BTreeMap<String, PathBuf>,
247}
248
249impl RevalidateSlot {
250 pub fn to_workload_payload(
258 &self,
259 env: BTreeMap<String, String>,
260 feeds: Vec<workload_spec::AlmanacFeed>,
261 feed_project_prefix: Option<String>,
262 ) -> MesofactRevalidateReceiver {
263 MesofactRevalidateReceiver {
264 routes: self.routes.clone(),
265 publish_config: self
266 .publish_config
267 .as_ref()
268 .map(|p| p.to_string_lossy().into_owned())
269 .unwrap_or_else(|| "mesofact.config.toml".to_string()),
270 mirror_key_env: self.mirror_key_env.clone(),
271 env,
272 feeds,
273 feed_interval_secs: self
274 .feed_interval_secs
275 .unwrap_or(DEFAULT_FEED_INTERVAL_SECS),
276 feed_project_prefix,
277 }
278 }
279}
280
281pub const DEFAULT_FEED_INTERVAL_SECS: u64 = 300;
285
286pub const FEED_BIN_NAME: &str = "almanac-feed";
289
290impl BundleSlot {
291 pub fn parse(mirror: &MirrorConfig, service: &str, env: &str) -> Result<Self> {
298 let slot = mirror.providers.get(SLOT_ROLE).with_context(|| {
299 format!(
300 "mirror has no `providers.{SLOT_ROLE}` slot — required for the W272 bundle tier \
301 (service={service}, env={env})"
302 )
303 })?;
304 let fields = slot.fields();
305
306 let bucket = fields
307 .get("bucket")
308 .and_then(|v| v.as_str())
309 .filter(|s| !s.is_empty())
310 .with_context(|| {
311 format!(
312 "providers.{SLOT_ROLE} has no `bucket` — name the R2 bundle store in \
313 .yah/services/{service}/mirrors/{env}.toml"
314 )
315 })?
316 .to_string();
317
318 let account = fields
319 .get("account")
320 .and_then(|v| v.as_str())
321 .filter(|s| !s.is_empty())
322 .map(str::to_string);
323
324 let name = fields
325 .get("name")
326 .and_then(|v| v.as_str())
327 .filter(|s| !s.is_empty())
328 .map(str::to_string);
329
330 let machines = match fields.get("machines") {
331 None => Vec::new(),
332 Some(v) => {
333 let list = v.as_array().with_context(|| {
334 format!(
335 "providers.{SLOT_ROLE}.machines must be an array of machine names \
336 (service={service}, env={env})"
337 )
338 })?;
339 list.iter()
340 .map(|entry| {
341 entry
342 .as_str()
343 .filter(|s| !s.is_empty())
344 .map(str::to_string)
345 .with_context(|| {
346 format!(
347 "providers.{SLOT_ROLE}.machines holds a non-string (or empty) \
348 entry (service={service}, env={env})"
349 )
350 })
351 })
352 .collect::<Result<Vec<_>>>()?
353 }
354 };
355
356 let runtime_version = fields
357 .get("runtime_version")
358 .and_then(|v| v.as_str())
359 .filter(|s| !s.is_empty())
360 .map(str::to_string);
361
362 let serve_bins = match fields.get("serve_bins") {
363 None => BTreeMap::new(),
364 Some(v) => {
365 let table = v.as_table().with_context(|| {
366 format!(
367 "providers.{SLOT_ROLE}.serve_bins must be a table of \
368 <target-triple> = <path> (service={service}, env={env})"
369 )
370 })?;
371 table
372 .iter()
373 .map(|(triple, path)| {
374 let path = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
375 format!(
376 "providers.{SLOT_ROLE}.serve_bins.{triple} must be a non-empty \
377 path (service={service}, env={env})"
378 )
379 })?;
380 Ok((triple.clone(), PathBuf::from(path)))
381 })
382 .collect::<Result<BTreeMap<_, _>>>()?
383 }
384 };
385
386 let port = match fields.get("port") {
391 None => None,
392 Some(v) => {
393 let n = v.as_integer().with_context(|| {
394 format!(
395 "providers.{SLOT_ROLE}.port must be an integer TCP port \
396 (service={service}, env={env})"
397 )
398 })?;
399 Some(u16::try_from(n).ok().filter(|p| *p != 0).with_context(|| {
400 format!(
401 "providers.{SLOT_ROLE}.port = {n} is not a usable TCP port \
402 (1..=65535) (service={service}, env={env})"
403 )
404 })?)
405 }
406 };
407
408 let lifecycle = parse_lifecycle(
409 fields.get("lifecycle").and_then(|v| v.as_str()),
410 fields.get("idle_ttl_ms").and_then(|v| v.as_integer()),
411 service,
412 env,
413 )?;
414
415 let revalidate = parse_revalidate_slot(fields, service, env)?;
416
417 let zone = fields
418 .get("zone")
419 .and_then(|v| v.as_str())
420 .filter(|s| !s.is_empty())
421 .map(str::to_string);
422
423 let verify_serving = match fields.get("verify_serving") {
429 None => true,
430 Some(v) => v.as_bool().with_context(|| {
431 format!(
432 "providers.{SLOT_ROLE}.verify_serving must be a boolean \
433 (service={service}, env={env})"
434 )
435 })?,
436 };
437
438 Ok(Self {
439 bucket,
440 account,
441 name,
442 machines,
443 runtime_version,
444 serve_bins,
445 lifecycle,
446 port,
447 revalidate,
448 zone,
449 verify_serving,
450 })
451 }
452
453 pub fn serving_zone<'a>(&'a self, service_domain: &'a str) -> &'a str {
456 self.zone.as_deref().unwrap_or(service_domain)
457 }
458
459 pub fn workload_name<'a>(&'a self, service: &'a str) -> &'a str {
461 self.name.as_deref().unwrap_or(service)
462 }
463
464 pub fn is_self_contained(&self) -> bool {
467 !self.serve_bins.is_empty()
468 }
469
470 pub fn serve_bundle(&self, digest: &str, runtime: &str) -> MesofactServeBundle {
477 MesofactServeBundle {
478 digest: BlakeHash(digest.to_string()),
479 runtime: runtime.to_string(),
480 lifecycle: self.lifecycle.clone(),
481 port: self.port,
486 }
487 }
488}
489
490fn parse_lifecycle(
492 raw: Option<&str>,
493 idle_ttl_ms: Option<i64>,
494 service: &str,
495 env: &str,
496) -> Result<BundleLifecycle> {
497 match raw.unwrap_or("keep-alive") {
498 "keep-alive" | "keepalive" => {
499 if idle_ttl_ms.is_some() {
500 bail!(
501 "providers.{SLOT_ROLE}.idle_ttl_ms only applies to `lifecycle = \"on-demand\"` \
502 — a keep-alive bundle is never reaped (service={service}, env={env})"
503 );
504 }
505 Ok(BundleLifecycle::KeepAlive)
506 }
507 "on-demand" | "ondemand" | "jit" => {
508 let ttl = idle_ttl_ms.unwrap_or(DEFAULT_IDLE_TTL_MS as i64);
509 if ttl <= 0 {
510 bail!(
511 "providers.{SLOT_ROLE}.idle_ttl_ms must be positive, got {ttl} \
512 (service={service}, env={env})"
513 );
514 }
515 Ok(BundleLifecycle::OnDemand {
516 idle_ttl: Millis::from_ms(ttl as u64),
517 })
518 }
519 other => bail!(
520 "providers.{SLOT_ROLE}.lifecycle must be \"keep-alive\" or \"on-demand\", got \
521 {other:?} (service={service}, env={env})"
522 ),
523 }
524}
525
526fn parse_revalidate_slot(
531 fields: &BTreeMap<String, toml::Value>,
532 service: &str,
533 env: &str,
534) -> Result<Option<RevalidateSlot>> {
535 let sub = match fields.get(REVALIDATE_KEY) {
536 None => return Ok(None),
537 Some(v) => v.as_table().with_context(|| {
538 format!(
539 "providers.{SLOT_ROLE}.{REVALIDATE_KEY} must be a TOML table \
540 (service={service}, env={env})"
541 )
542 })?,
543 };
544
545 let routes = match sub.get("routes") {
546 None => Vec::new(),
547 Some(v) => {
548 let list = v.as_array().with_context(|| {
549 format!(
550 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes must be an array of route \
551 patterns (service={service}, env={env})"
552 )
553 })?;
554 list.iter()
555 .map(|entry| {
556 entry
557 .as_str()
558 .filter(|s| !s.is_empty())
559 .map(str::to_string)
560 .with_context(|| {
561 format!(
562 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes holds a non-string \
563 (or empty) entry (service={service}, env={env})"
564 )
565 })
566 })
567 .collect::<Result<Vec<_>>>()?
568 }
569 };
570
571 let mirror_key_env = sub
572 .get("mirror_key_env")
573 .and_then(|v| v.as_str())
574 .filter(|s| !s.is_empty())
575 .map(str::to_string);
576
577 let publish_config = sub
578 .get("publish_config")
579 .and_then(|v| v.as_str())
580 .filter(|s| !s.is_empty())
581 .map(PathBuf::from);
582
583 let feeds = match sub.get("feeds") {
585 None => Vec::new(),
586 Some(v) => {
587 let list = v.as_array().with_context(|| {
588 format!(
589 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds must be an array of almanac \
590 feed names (service={service}, env={env})"
591 )
592 })?;
593 list.iter()
594 .map(|entry| {
595 entry
596 .as_str()
597 .filter(|s| !s.is_empty())
598 .map(str::to_string)
599 .with_context(|| {
600 format!(
601 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds holds a non-string \
602 (or empty) entry (service={service}, env={env})"
603 )
604 })
605 })
606 .collect::<Result<Vec<_>>>()?
607 }
608 };
609
610 let feed_interval_secs = match sub.get("feed_interval_secs") {
611 None => None,
612 Some(v) => {
613 let secs = v.as_integer().filter(|n| *n > 0).with_context(|| {
614 format!(
615 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_interval_secs must be a positive \
616 integer number of seconds (service={service}, env={env})"
617 )
618 })?;
619 Some(secs as u64)
620 }
621 };
622
623 let feed_bins = match sub.get("feed_bins") {
624 None => BTreeMap::new(),
625 Some(v) => {
626 let table = v.as_table().with_context(|| {
627 format!(
628 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins must be a table of \
629 <target-triple> = <path> (service={service}, env={env})"
630 )
631 })?;
632 table
633 .iter()
634 .map(|(triple, path)| {
635 let p = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
636 format!(
637 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple} must be \
638 a non-empty path string (service={service}, env={env})"
639 )
640 })?;
641 Ok((triple.clone(), PathBuf::from(p)))
642 })
643 .collect::<Result<BTreeMap<_, _>>>()?
644 }
645 };
646
647 if !feeds.is_empty() && feed_bins.is_empty() {
651 anyhow::bail!(
652 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds declares {} feed(s) but no feed_bins — \
653 the node has no other way to get the `{FEED_BIN_NAME}` fetcher, so nothing would \
654 ever refresh them (service={service}, env={env})",
655 feeds.len()
656 );
657 }
658
659 Ok(Some(RevalidateSlot {
660 routes,
661 mirror_key_env,
662 publish_config,
663 feeds,
664 feed_interval_secs,
665 feed_bins,
666 }))
667}
668
669pub fn resolve_bundle_machines<'a>(
683 cfg: &'a CloudConfig,
684 mirror: &MirrorConfig,
685 slot: &BundleSlot,
686 service: &str,
687 env: &str,
688) -> Result<Vec<&'a crate::MachineConfig>> {
689 if !slot.machines.is_empty() {
690 return slot
691 .machines
692 .iter()
693 .map(|name| {
694 cfg.machine(name).with_context(|| {
695 format!(
696 "providers.{SLOT_ROLE}.machines names {name:?}, which is not declared in \
697 .yah/infra/machines/ (service={service}, env={env})"
698 )
699 })
700 })
701 .collect();
702 }
703
704 let required = mirror
705 .providers
706 .get(SLOT_ROLE)
707 .and_then(|s| s.required())
708 .filter(|r| !r.is_unconstrained())
709 .with_context(|| {
710 format!(
711 "providers.{SLOT_ROLE} declares neither `machines = [...]` nor a constrained \
712 `required = {{ … }}` placement — a bundle must name the nodes that serve it \
713 (service={service}, env={env})"
714 )
715 })?;
716
717 let machine = cfg.resolve_machine(&required).with_context(|| {
718 format!(
719 "F16 placement: no machine satisfies providers.{SLOT_ROLE}.required ({}) — check \
720 .yah/services/{service}/mirrors/{env}.toml against .yah/infra/machines/*.toml",
721 required.describe(),
722 )
723 })?;
724 Ok(vec![machine])
725}
726
727pub struct MesofactBundleReconciler;
735
736impl MesofactBundleReconciler {
737 pub fn new() -> Self {
738 Self
739 }
740}
741
742impl Default for MesofactBundleReconciler {
743 fn default() -> Self {
744 Self::new()
745 }
746}
747
748#[async_trait]
749impl Reconciler for MesofactBundleReconciler {
750 fn kind(&self) -> &'static str {
751 super::mesofact_static::WORKLOAD_KIND
752 }
753
754 async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
755 let slot = BundleSlot::parse(ctx.mirror, &ctx.service.name, ctx.env)?;
756 bail!(
757 "bundle tier validated (bucket={}, workload={}, {} serve binar{}) for service={}, \
758 env={}, but the sync arm runs at the apply layer — deploy with \
759 `yah cloud mirror up {} --env {}` (machine placement needs the workspace's \
760 machine set, which a desktop bring-up does not load)",
761 slot.bucket,
762 slot.workload_name(&ctx.service.name),
763 slot.serve_bins.len(),
764 if slot.serve_bins.len() == 1 {
765 "y"
766 } else {
767 "ies"
768 },
769 ctx.service.name,
770 ctx.env,
771 ctx.service.name,
772 ctx.env,
773 )
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use crate::config::{MachineConfig, MirrorProviderSlot, MirrorShape, TopologyConfig};
781 use std::path::PathBuf;
782
783 fn mirror_from(slots: BTreeMap<String, MirrorProviderSlot>) -> MirrorConfig {
784 MirrorConfig {
785 schema_version: 1,
786 shape: MirrorShape::SingleMachine,
787 providers: slots,
788 ingress: Default::default(),
789 drivers: Default::default(),
790 asset_aliases: BTreeMap::new(),
791 }
792 }
793
794 fn mirror_with(slot_toml: &str) -> MirrorConfig {
796 let slot: MirrorProviderSlot = toml::from_str(slot_toml).unwrap();
797 let mut providers = BTreeMap::new();
798 providers.insert(SLOT_ROLE.to_string(), slot);
799 mirror_from(providers)
800 }
801
802 fn machine(name: &str, region: &str) -> MachineConfig {
803 MachineConfig {
804 name: name.into(),
805 provider: "static".into(),
806 location: None,
807 server_type: None,
808 hosts_mirrors: vec![],
809 mesh_tags: vec![],
810 region: Some(region.into()),
811 zone: None,
812 arch: Some("x86_64".into()),
813 bucket: None,
814 vendor: None,
815 nickname: None,
816 legacy_hostkey_fingerprint: None,
817 registration: Default::default(),
818 ssh_keys: vec![],
819 cloudflared: None,
820 hosts_operator_bridge: false,
821 connect: None,
822 allocatable: None,
823 taints: vec![],
824 }
825 }
826
827 fn cfg_with(machines: Vec<MachineConfig>) -> CloudConfig {
828 CloudConfig {
829 workspace_root: PathBuf::new(),
830 machines,
831 providers: vec![],
832 machine_origins: BTreeMap::new(),
833 provider_origins: BTreeMap::new(),
834 services: BTreeMap::new(),
835 domains: BTreeMap::new(),
836 legacy_mirrors: vec![],
837 workloads: vec![],
838 topology: TopologyConfig::default(),
839 legacy_services: vec![],
840 }
841 }
842
843 #[test]
844 fn slot_declared_keys_off_the_bundle_role() {
845 assert!(slot_declared(&mirror_with(
846 r#"use = "cloudflare"
847bucket = "b""#
848 )));
849 assert!(!slot_declared(&mirror_from(BTreeMap::new())));
850 }
851
852 #[test]
859 fn a_declared_slot_whose_serve_bin_is_absent_is_not_ready() {
860 let root = tempfile::tempdir().unwrap();
861 let mirror = mirror_with(
862 r#"
863use = "cloudflare"
864bucket = "yah-dev"
865
866[serve_bins]
867x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
868"#,
869 );
870 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
871
872 assert!(slot_declared(&mirror), "config declares the slot");
873 assert!(
874 !slot_ready(&slot, root.path()),
875 "but it cannot serve — the binary does not exist"
876 );
877
878 let missing = missing_bins(&slot, root.path());
879 assert_eq!(missing.len(), 1);
880 assert_eq!(
881 missing[0].0, "providers.bundle.serve_bins.x86_64-unknown-linux-musl",
882 "the label must name the exact config line to fix"
883 );
884 }
885
886 #[test]
887 fn a_slot_becomes_ready_once_its_bins_exist() {
888 let root = tempfile::tempdir().unwrap();
889 let bin = root.path().join("target/x86_64-unknown-linux-musl/release");
890 std::fs::create_dir_all(&bin).unwrap();
891 std::fs::write(bin.join("mesofact"), b"#!/bin/sh\n").unwrap();
892
893 let mirror = mirror_with(
894 r#"
895use = "cloudflare"
896bucket = "yah-dev"
897
898[serve_bins]
899x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
900"#,
901 );
902 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
903 assert!(slot_ready(&slot, root.path()));
904 assert!(missing_bins(&slot, root.path()).is_empty());
905 }
906
907 #[test]
911 fn a_missing_feed_bin_also_blocks_readiness() {
912 let root = tempfile::tempdir().unwrap();
913 let bin = root.path().join("target/musl");
914 std::fs::create_dir_all(&bin).unwrap();
915 std::fs::write(bin.join("mesofact"), b"x").unwrap();
916
917 let mirror = mirror_with(
918 r#"
919use = "cloudflare"
920bucket = "yah-dev"
921
922[serve_bins]
923x86_64-unknown-linux-musl = "target/musl/mesofact"
924
925[revalidate]
926routes = ["/releases"]
927feeds = ["releases"]
928
929[revalidate.feed_bins]
930x86_64-unknown-linux-musl = "target/musl/almanac-feed"
931"#,
932 );
933 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
934 let missing = missing_bins(&slot, root.path());
935 assert_eq!(missing.len(), 1, "only the feed binary is absent");
936 assert!(missing[0].0.contains("revalidate.feed_bins"));
937 assert!(!slot_ready(&slot, root.path()));
938 }
939
940 #[test]
944 fn a_vanilla_slot_declaring_no_bins_is_ready() {
945 let root = tempfile::tempdir().unwrap();
946 let mirror = mirror_with(
947 r#"use = "cloudflare"
948bucket = "b""#,
949 );
950 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
951 assert!(!slot.is_self_contained());
952 assert!(slot_ready(&slot, root.path()));
953 }
954
955 #[test]
956 fn parses_a_self_contained_keep_alive_slot() {
957 let mirror = mirror_with(
958 r#"
959use = "cloudflare"
960bucket = "yah-dev-bundles"
961machines = ["us-east-001"]
962name = "yah-marketing"
963
964[serve_bins]
965x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact-serve"
966"#,
967 );
968 let slot = BundleSlot::parse(&mirror, "yah-marketing", "ha").unwrap();
969 assert_eq!(slot.bucket, "yah-dev-bundles");
970 assert_eq!(slot.machines, vec!["us-east-001".to_string()]);
971 assert_eq!(slot.workload_name("yah-marketing"), "yah-marketing");
972 assert!(slot.is_self_contained());
973 assert_eq!(slot.lifecycle, BundleLifecycle::KeepAlive);
974 }
975
976 #[test]
977 fn workload_name_falls_back_to_the_service_name() {
978 let mirror = mirror_with(
979 r#"use = "cloudflare"
980bucket = "b""#,
981 );
982 let slot = BundleSlot::parse(&mirror, "scrabcake", "ha").unwrap();
983 assert_eq!(slot.workload_name("scrabcake"), "scrabcake");
984 assert!(!slot.is_self_contained());
985 }
986
987 #[test]
988 fn on_demand_takes_the_default_idle_ttl() {
989 let mirror = mirror_with(
990 r#"use = "cloudflare"
991bucket = "b"
992lifecycle = "on-demand""#,
993 );
994 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
995 assert_eq!(
996 slot.lifecycle,
997 BundleLifecycle::OnDemand {
998 idle_ttl: Millis::from_ms(DEFAULT_IDLE_TTL_MS)
999 }
1000 );
1001 }
1002
1003 #[test]
1004 fn on_demand_honors_an_explicit_idle_ttl() {
1005 let mirror = mirror_with(
1006 r#"use = "cloudflare"
1007bucket = "b"
1008lifecycle = "on-demand"
1009idle_ttl_ms = 15000"#,
1010 );
1011 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1012 assert_eq!(
1013 slot.lifecycle,
1014 BundleLifecycle::OnDemand {
1015 idle_ttl: Millis::from_ms(15_000)
1016 }
1017 );
1018 }
1019
1020 #[test]
1023 fn idle_ttl_on_a_keep_alive_slot_is_rejected() {
1024 let mirror = mirror_with(
1025 r#"use = "cloudflare"
1026bucket = "b"
1027idle_ttl_ms = 15000"#,
1028 );
1029 let err = BundleSlot::parse(&mirror, "s", "e")
1030 .unwrap_err()
1031 .to_string();
1032 assert!(err.contains("idle_ttl_ms"), "{err}");
1033 assert!(err.contains("on-demand"), "{err}");
1034 }
1035
1036 #[test]
1037 fn unknown_lifecycle_names_the_legal_values() {
1038 let mirror = mirror_with(
1039 r#"use = "cloudflare"
1040bucket = "b"
1041lifecycle = "serverless""#,
1042 );
1043 let err = BundleSlot::parse(&mirror, "s", "e")
1044 .unwrap_err()
1045 .to_string();
1046 assert!(err.contains("keep-alive"), "{err}");
1047 assert!(err.contains("on-demand"), "{err}");
1048 }
1049
1050 #[test]
1054 fn a_declared_port_reaches_the_serve_bundle() {
1055 let slot = BundleSlot::parse(
1056 &mirror_with(
1057 r#"use = "cloudflare"
1058bucket = "b"
1059port = 8081"#,
1060 ),
1061 "s",
1062 "e",
1063 )
1064 .unwrap();
1065 assert_eq!(slot.port, Some(8081));
1066 assert_eq!(
1067 slot.serve_bundle("a".repeat(64).as_str(), "self").port,
1068 Some(8081)
1069 );
1070
1071 let bare = BundleSlot::parse(
1073 &mirror_with(
1074 r#"use = "cloudflare"
1075bucket = "b""#,
1076 ),
1077 "s",
1078 "e",
1079 )
1080 .unwrap();
1081 assert_eq!(bare.port, None);
1082 assert_eq!(
1083 bare.serve_bundle("a".repeat(64).as_str(), "self").port,
1084 None
1085 );
1086 }
1087
1088 #[test]
1092 fn an_unusable_port_is_rejected_rather_than_defaulted() {
1093 for bad in ["port = 0", "port = 70000", r#"port = "8081""#] {
1094 let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1095 let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1096 .unwrap_err()
1097 .to_string();
1098 assert!(err.contains("port"), "{bad}: {err}");
1099 }
1100 }
1101
1102 #[test]
1107 fn serving_verification_is_on_by_default_and_targets_the_service_domain() {
1108 let slot = BundleSlot::parse(
1109 &mirror_with(
1110 r#"use = "cloudflare"
1111bucket = "b""#,
1112 ),
1113 "yah-marketing",
1114 "cloud",
1115 )
1116 .unwrap();
1117 assert!(slot.verify_serving);
1118 assert_eq!(slot.zone, None);
1119 assert_eq!(slot.serving_zone("yah.dev"), "yah.dev");
1120 }
1121
1122 #[test]
1123 fn an_explicit_zone_overrides_the_service_domain() {
1124 let slot = BundleSlot::parse(
1125 &mirror_with(
1126 r#"use = "cloudflare"
1127bucket = "b"
1128zone = "staging.yah.dev""#,
1129 ),
1130 "yah-marketing",
1131 "cloud",
1132 )
1133 .unwrap();
1134 assert_eq!(slot.serving_zone("yah.dev"), "staging.yah.dev");
1135 }
1136
1137 #[test]
1138 fn verify_serving_can_be_switched_off_for_an_in_flight_migration() {
1139 let slot = BundleSlot::parse(
1140 &mirror_with(
1141 r#"use = "cloudflare"
1142bucket = "b"
1143verify_serving = false"#,
1144 ),
1145 "s",
1146 "e",
1147 )
1148 .unwrap();
1149 assert!(!slot.verify_serving);
1150 }
1151
1152 #[test]
1155 fn a_non_boolean_verify_serving_is_rejected_rather_than_defaulted() {
1156 let err = BundleSlot::parse(
1157 &mirror_with(
1158 r#"use = "cloudflare"
1159bucket = "b"
1160verify_serving = "false""#,
1161 ),
1162 "yah-marketing",
1163 "cloud",
1164 )
1165 .unwrap_err()
1166 .to_string();
1167 assert!(err.contains("verify_serving"), "{err}");
1168 assert!(err.contains("boolean"), "{err}");
1169 }
1170
1171 #[test]
1172 fn a_slot_without_a_bucket_names_the_file_to_edit() {
1173 let mirror = mirror_with(r#"use = "cloudflare""#);
1174 let err = BundleSlot::parse(&mirror, "yah-marketing", "ha")
1175 .unwrap_err()
1176 .to_string();
1177 assert!(err.contains("bucket"), "{err}");
1178 assert!(
1179 err.contains(".yah/services/yah-marketing/mirrors/ha.toml"),
1180 "{err}"
1181 );
1182 }
1183
1184 #[test]
1185 fn explicit_machines_resolve_in_declaration_order() {
1186 let mirror = mirror_with(
1187 r#"use = "cloudflare"
1188bucket = "b"
1189machines = ["us-south-001", "us-east-001"]"#,
1190 );
1191 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1192 let cfg = cfg_with(vec![
1193 machine("us-east-001", "us-east"),
1194 machine("us-south-001", "us-south"),
1195 ]);
1196 let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
1197 let names: Vec<_> = resolved.iter().map(|m| m.name.as_str()).collect();
1198 assert_eq!(names, vec!["us-south-001", "us-east-001"]);
1199 }
1200
1201 #[test]
1202 fn an_undeclared_machine_is_an_error_not_a_skip() {
1203 let mirror = mirror_with(
1204 r#"use = "cloudflare"
1205bucket = "b"
1206machines = ["us-west-999"]"#,
1207 );
1208 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1209 let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
1210 let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
1211 .unwrap_err()
1212 .to_string();
1213 assert!(err.contains("us-west-999"), "{err}");
1214 assert!(err.contains(".yah/infra/machines/"), "{err}");
1215 }
1216
1217 #[test]
1218 fn falls_back_to_f16_required_placement() {
1219 let mirror = mirror_with(
1220 r#"use = "cloudflare"
1221bucket = "b"
1222required = { regions = ["us-east"] }"#,
1223 );
1224 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1225 let cfg = cfg_with(vec![
1226 machine("us-east-001", "us-east"),
1227 machine("us-south-001", "us-south"),
1228 ]);
1229 let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
1230 assert_eq!(resolved.len(), 1);
1231 assert_eq!(resolved[0].name, "us-east-001");
1232 }
1233
1234 #[test]
1236 fn no_placement_at_all_is_rejected() {
1237 let mirror = mirror_with(
1238 r#"use = "cloudflare"
1239bucket = "b""#,
1240 );
1241 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1242 let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
1243 let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
1244 .unwrap_err()
1245 .to_string();
1246 assert!(err.contains("machines"), "{err}");
1247 assert!(err.contains("required"), "{err}");
1248 }
1249
1250 #[test]
1251 fn serve_bundle_carries_the_manifest_runtime_verbatim() {
1252 let mirror = mirror_with(
1253 r#"use = "cloudflare"
1254bucket = "b""#,
1255 );
1256 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1257 let digest = "a".repeat(64);
1258 let sb = slot.serve_bundle(&digest, "mesofact/0.8.20");
1259 assert_eq!(sb.digest.0, digest);
1260 assert_eq!(sb.runtime, "mesofact/0.8.20");
1261 assert_eq!(sb.lifecycle, BundleLifecycle::KeepAlive);
1262 }
1263
1264 #[test]
1267 fn parses_revalidate_slot_with_routes_and_mirror_key_env() {
1268 let mirror = mirror_with(
1269 r#"use = "cloudflare"
1270bucket = "b"
1271machines = ["us-east-001"]
1272
1273[revalidate]
1274routes = ["/releases"]
1275mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
1276"#,
1277 );
1278 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1279 let rv = slot.revalidate.expect("revalidate slot should parse");
1280 assert_eq!(rv.routes, vec!["/releases"]);
1281 assert_eq!(
1282 rv.mirror_key_env.as_deref(),
1283 Some("YAH_MARKETING_MIRROR_KEY")
1284 );
1285 assert!(rv.publish_config.is_none());
1286 }
1287
1288 #[test]
1289 fn parses_revalidate_with_custom_publish_config() {
1290 let mirror = mirror_with(
1291 r#"use = "cloudflare"
1292bucket = "b"
1293machines = ["us-east-001"]
1294
1295[revalidate]
1296routes = ["/releases", "/downloads"]
1297publish_config = "custom-mesofact.config.toml"
1298"#,
1299 );
1300 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1301 let rv = slot.revalidate.unwrap();
1302 assert_eq!(rv.routes.len(), 2);
1303 assert_eq!(
1304 rv.publish_config.unwrap(),
1305 PathBuf::from("custom-mesofact.config.toml")
1306 );
1307 assert!(rv.mirror_key_env.is_none());
1308 }
1309
1310 #[test]
1311 fn no_revalidate_when_section_absent() {
1312 let mirror = mirror_with(
1313 r#"use = "cloudflare"
1314bucket = "b""#,
1315 );
1316 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1317 assert!(slot.revalidate.is_none());
1318 }
1319
1320 #[test]
1321 fn revalidate_with_empty_routes_is_open_allowlist() {
1322 let mirror = mirror_with(
1323 r#"use = "cloudflare"
1324bucket = "b"
1325
1326[revalidate]
1327mirror_key_env = "BEARER"
1328"#,
1329 );
1330 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1331 let rv = slot.revalidate.unwrap();
1332 assert!(rv.routes.is_empty());
1333 assert_eq!(rv.mirror_key_env.as_deref(), Some("BEARER"));
1334 }
1335
1336 #[test]
1337 fn to_workload_payload_maps_fields() {
1338 let slot = RevalidateSlot {
1339 routes: vec!["/releases".into()],
1340 mirror_key_env: Some("MY_KEY".into()),
1341 publish_config: Some(PathBuf::from("cfg.toml")),
1342 ..bare_revalidate_slot()
1343 };
1344 let mut env = BTreeMap::new();
1345 env.insert("MESOFACT_S3_ACCESS_KEY_ID".into(), "ak".into());
1346 env.insert("MESOFACT_MIRROR_KEY".into(), "bearer1".into());
1347 let payload = slot.to_workload_payload(env.clone(), vec![], None);
1348 assert_eq!(payload.routes, vec!["/releases"]);
1349 assert_eq!(payload.publish_config, "cfg.toml");
1350 assert_eq!(payload.mirror_key_env.as_deref(), Some("MY_KEY"));
1351 assert_eq!(payload.env.get("MESOFACT_S3_ACCESS_KEY_ID").unwrap(), "ak");
1352 assert_eq!(payload.env.get("MESOFACT_MIRROR_KEY").unwrap(), "bearer1");
1353 }
1354
1355 #[test]
1356 fn to_workload_payload_defaults_publish_config() {
1357 let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
1358 assert_eq!(payload.publish_config, "mesofact.config.toml");
1359 assert!(payload.mirror_key_env.is_none());
1360 assert!(payload.routes.is_empty());
1361 }
1362
1363 fn bare_revalidate_slot() -> RevalidateSlot {
1366 RevalidateSlot {
1367 routes: vec![],
1368 mirror_key_env: None,
1369 publish_config: None,
1370 feeds: vec![],
1371 feed_interval_secs: None,
1372 feed_bins: BTreeMap::new(),
1373 }
1374 }
1375
1376 #[test]
1377 fn parses_feed_tier_declaration() {
1378 let mirror = mirror_with(
1379 r#"use = "cloudflare"
1380bucket = "b"
1381
1382[revalidate]
1383routes = ["/releases"]
1384feeds = ["releases", "yah-desktop"]
1385feed_interval_secs = 60
1386
1387[revalidate.feed_bins]
1388x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/almanac-feed"
1389"#,
1390 );
1391 let rv = BundleSlot::parse(&mirror, "s", "e")
1392 .unwrap()
1393 .revalidate
1394 .unwrap();
1395 assert_eq!(rv.feeds, vec!["releases", "yah-desktop"]);
1396 assert_eq!(rv.feed_interval_secs, Some(60));
1397 assert_eq!(rv.feed_bins.len(), 1);
1398 assert!(rv.feed_bins["x86_64-unknown-linux-musl"].ends_with("almanac-feed"));
1399 }
1400
1401 #[test]
1404 fn revalidate_without_a_feed_tier_stays_empty() {
1405 let mirror = mirror_with(
1406 r#"use = "cloudflare"
1407bucket = "b"
1408
1409[revalidate]
1410routes = ["/releases"]
1411"#,
1412 );
1413 let rv = BundleSlot::parse(&mirror, "s", "e")
1414 .unwrap()
1415 .revalidate
1416 .unwrap();
1417 assert!(rv.feeds.is_empty());
1418 assert!(rv.feed_bins.is_empty());
1419 assert_eq!(rv.feed_interval_secs, None);
1420 }
1421
1422 #[test]
1425 fn feeds_without_feed_bins_is_rejected() {
1426 let mirror = mirror_with(
1427 r#"use = "cloudflare"
1428bucket = "b"
1429
1430[revalidate]
1431feeds = ["releases"]
1432"#,
1433 );
1434 let err = BundleSlot::parse(&mirror, "s", "e")
1435 .unwrap_err()
1436 .to_string();
1437 assert!(err.contains("feed_bins"), "got {err}");
1438 assert!(err.contains(FEED_BIN_NAME), "got {err}");
1439 }
1440
1441 #[test]
1442 fn zero_feed_interval_is_rejected() {
1443 let mirror = mirror_with(
1444 r#"use = "cloudflare"
1445bucket = "b"
1446
1447[revalidate]
1448feed_interval_secs = 0
1449"#,
1450 );
1451 let err = BundleSlot::parse(&mirror, "s", "e")
1452 .unwrap_err()
1453 .to_string();
1454 assert!(err.contains("positive integer"), "got {err}");
1455 }
1456
1457 #[test]
1460 fn feed_interval_default_matches_the_workload_spec_default() {
1461 let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
1462 assert_eq!(payload.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
1463
1464 let from_spec: workload_spec::MesofactRevalidateReceiver =
1465 serde_json::from_str("{}").expect("all receiver fields have serde defaults");
1466 assert_eq!(from_spec.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
1467 }
1468}