1use crate::time::now_unix;
19use std::collections::{BTreeMap, BTreeSet};
20use std::sync::{Arc, Mutex};
21
22use futures::StreamExt;
23use sha2::{Digest, Sha256};
24
25use crate::config::SiteConfig;
26use crate::domain_verify::{DomainVerification, VerificationMethod};
27use crate::error::DeployError;
28use crate::kv::{KvStore, WriteOp};
29use crate::project::{DomainOwner, ProjectRef};
30use crate::site::SiteName;
31use crate::{ByteStream, GetObject, PutMeta, Storage, StorageError};
32
33pub use boatramp_types::file::{FileEntry, Variant};
40pub use boatramp_types::manifest::{sha256_hex, Manifest};
41
42pub use boatramp_types::deploy::{
48 BlobMismatch, BlobReadError, DeployMeta, DeployMetaInput, DeploymentList, GcReport,
49 HistoryEntry, ScrubReport,
50};
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct GcOptions {
57 pub grace_secs: u64,
61 pub keep_last: Option<usize>,
65 pub keep_age_secs: Option<u64>,
68}
69
70const MAX_HISTORY: usize = 100;
72
73fn canon_host(host: &str) -> String {
80 crate::host::Host::new(host).routing_key()
81}
82
83fn backfill_replica_project(state: &mut crate::compute::ObservedInstance, project: &str) {
89 if state.handle.project.is_empty() {
90 state.handle.project = project.to_string();
91 }
92 if let Some(snap) = state.snapshot.as_mut() {
93 if snap.project.is_empty() {
94 snap.project = project.to_string();
95 }
96 }
97}
98
99fn is_blob_key(key: &str) -> bool {
102 match key.split_once('/') {
103 Some((shard, hash)) => {
104 shard.len() == 2
105 && hash.len() == 64
106 && shard.bytes().all(|b| b.is_ascii_hexdigit())
107 && hash.bytes().all(|b| b.is_ascii_hexdigit())
108 }
109 None => false,
110 }
111}
112
113#[derive(Clone)]
122pub struct DeployStore(Arc<DeployStoreInner>);
123
124impl std::ops::Deref for DeployStore {
125 type Target = DeployStoreInner;
126 fn deref(&self) -> &DeployStoreInner {
127 &self.0
128 }
129}
130
131pub struct DeployStoreInner {
133 storage: Arc<dyn Storage>,
134 kv: Arc<dyn KvStore>,
135 domain_claim_lock: Arc<futures::lock::Mutex<()>>,
142 site_config_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, Arc<SiteConfig>>>>,
150 blob_body_cache: Arc<std::sync::RwLock<BlobBodyCache>>,
157 domain_cache: Arc<std::sync::RwLock<DomainResolveCache>>,
165 domain_epoch: Arc<std::sync::atomic::AtomicU64>,
171}
172
173#[derive(Default)]
176struct DomainResolveCache {
177 epoch: u64,
178 map: std::collections::HashMap<String, Option<DomainOwner>>,
179}
180
181pub enum BlobBody {
184 Cached(bytes::Bytes),
186 Mapped(bytes::Bytes),
190 Stream(GetObject),
192}
193
194#[derive(Default)]
199struct BlobBodyCache {
200 map: std::collections::HashMap<String, bytes::Bytes>,
201 bytes: usize,
202}
203
204const SMALL_BLOB_CACHE_MAX: u64 = 256 * 1024;
207const BLOB_BODY_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
209
210pub(crate) mod keys {
211 use crate::project::ProjectRef;
228
229 pub fn manifest(id: &str) -> String {
231 format!("manifests/{id}")
232 }
233
234 pub fn meta(id: &str) -> String {
236 format!("meta/{id}")
237 }
238
239 pub fn current(project: ProjectRef<'_>, site: &str) -> String {
241 format!("project/{project}/current/{site}")
242 }
243
244 pub fn current_prefix(project: ProjectRef<'_>) -> String {
246 format!("project/{project}/current/")
247 }
248
249 pub fn alias(project: ProjectRef<'_>, site: &str, name: &str) -> String {
251 format!("project/{project}/alias/{site}/{name}")
252 }
253
254 pub fn alias_prefix(project: ProjectRef<'_>, site: &str) -> String {
256 format!("project/{project}/alias/{site}/")
257 }
258
259 pub fn alias_project_prefix(project: ProjectRef<'_>) -> String {
261 format!("project/{project}/alias/")
262 }
263
264 pub fn secret(project: ProjectRef<'_>, name: &str) -> String {
270 format!("project/{project}/secret/{name}")
271 }
272
273 pub fn secret_prefix(project: ProjectRef<'_>) -> String {
275 format!("project/{project}/secret/")
276 }
277
278 pub fn email_profile(project: ProjectRef<'_>, name: &str) -> String {
285 format!("project/{project}/email/{name}")
286 }
287
288 pub fn email_profile_prefix(project: ProjectRef<'_>) -> String {
290 format!("project/{project}/email/")
291 }
292
293 pub fn graphql_safelist_prefix(project: ProjectRef<'_>) -> String {
299 format!("hapq/{project}/")
300 }
301
302 pub fn graphql_registry_prefix(project: ProjectRef<'_>) -> String {
307 format!("graphql/{project}/")
308 }
309
310 pub fn graphql_subgraph_prefix(project: ProjectRef<'_>) -> String {
315 format!("graphql/{project}/subgraph/")
316 }
317
318 pub fn blob(hash: &str) -> String {
320 if hash.len() >= 2 {
321 format!("{}/{}", &hash[..2], hash)
322 } else {
323 hash.to_string()
324 }
325 }
326
327 pub fn site_pointer(project: ProjectRef<'_>, site: &str) -> String {
331 format!("project/{project}/site/{site}")
332 }
333
334 pub fn site_prefix(project: ProjectRef<'_>) -> String {
336 format!("project/{project}/site/")
337 }
338
339 pub fn site_config_blob(hash: &str) -> String {
344 format!("siteconfig/{hash}")
345 }
346
347 pub fn domain(host: &str) -> String {
351 format!("domain/{}", super::canon_host(host))
352 }
353
354 pub fn wildcard(suffix: &str) -> String {
357 format!("wildcard/{}", super::canon_host(suffix))
358 }
359
360 pub fn domain_verification(project: ProjectRef<'_>, site: &str, host: &str) -> String {
363 format!(
364 "project/{project}/domainverify/{site}/{}",
365 crate::domain_verify::normalize_host(host)
366 )
367 }
368
369 pub fn domain_verification_prefix(project: ProjectRef<'_>, site: &str) -> String {
371 format!("project/{project}/domainverify/{site}/")
372 }
373
374 pub fn domain_verification_project_prefix(project: ProjectRef<'_>) -> String {
376 format!("project/{project}/domainverify/")
377 }
378
379 pub fn http_challenge_index(host: &str, token: &str) -> String {
385 format!(
386 "httpchallenge/{}/{token}",
387 crate::domain_verify::normalize_host(host)
388 )
389 }
390
391 pub fn daemon_config_blob(hash: &str) -> String {
394 format!("daemonconfig/{hash}")
395 }
396
397 pub fn history(project: ProjectRef<'_>, site: &str) -> String {
399 format!("project/{project}/history/{site}")
400 }
401
402 pub fn history_prefix(project: ProjectRef<'_>) -> String {
404 format!("project/{project}/history/")
405 }
406
407 pub const PROJECT_ROOT: &str = "project/";
411
412 pub fn project_of_key(key: &str) -> Option<&str> {
415 key.strip_prefix(PROJECT_ROOT)
416 .and_then(|rest| rest.split('/').next())
417 .filter(|p| !p.is_empty())
418 }
419}
420
421#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
427pub struct ProjectTeardownPlan {
428 pub project: String,
430 pub sites: Vec<String>,
433 pub functions: Vec<String>,
435 pub compute: Vec<ComputeTeardown>,
438 pub secrets: Vec<String>,
440 pub safelist: usize,
442 pub subgraphs: Vec<String>,
444 pub other_families: std::collections::BTreeMap<String, usize>,
448}
449
450impl ProjectTeardownPlan {
451 pub fn is_empty(&self) -> bool {
454 self.sites.is_empty()
455 && self.functions.is_empty()
456 && self.compute.is_empty()
457 && self.secrets.is_empty()
458 && self.safelist == 0
459 && self.subgraphs.is_empty()
460 && self.other_families.is_empty()
461 }
462
463 pub fn all_volumes(&self) -> Vec<String> {
467 let mut vols: std::collections::BTreeSet<String> = Default::default();
468 for c in &self.compute {
469 for v in &c.volumes {
470 vols.insert(v.clone());
471 }
472 }
473 vols.into_iter().collect()
474 }
475}
476
477#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
480pub struct ComputeTeardown {
481 pub name: String,
483 pub volumes: Vec<String>,
485}
486
487impl DeployStore {
488 pub fn new(storage: Arc<dyn Storage>, kv: Arc<dyn KvStore>) -> Self {
490 Self(Arc::new(DeployStoreInner {
491 storage,
492 kv,
493 domain_claim_lock: Arc::new(futures::lock::Mutex::new(())),
494 site_config_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
495 blob_body_cache: Arc::new(std::sync::RwLock::new(BlobBodyCache::default())),
496 domain_cache: Arc::new(std::sync::RwLock::new(DomainResolveCache::default())),
497 domain_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)),
498 }))
499 }
500
501 pub fn kv(&self) -> &Arc<dyn KvStore> {
504 &self.kv
505 }
506
507 pub async fn ready(&self) -> Result<(), DeployError> {
511 self.kv.get("__readyz_probe__").await?;
512 Ok(())
513 }
514
515 pub async fn put_manifest(&self, manifest: &Manifest) -> Result<String, DeployError> {
517 self.put_manifest_with(manifest, DeployMetaInput::default())
518 .await
519 }
520
521 pub async fn put_manifest_with(
529 &self,
530 manifest: &Manifest,
531 input: DeployMetaInput,
532 ) -> Result<String, DeployError> {
533 let id = manifest.id()?;
534
535 let existing = self.get_meta(&id).await?;
539 let created_at = existing
540 .as_ref()
541 .map(|m| m.created_at)
542 .unwrap_or_else(now_unix);
543 let meta = DeployMeta {
544 version: crate::SCHEMA_VERSION,
545 created_at,
546 file_count: manifest.files.len() as u64,
547 total_size: manifest.files.values().map(|entry| entry.size).sum(),
548 source: input
549 .source
550 .or_else(|| existing.as_ref().and_then(|m| m.source.clone())),
551 branch: input
552 .branch
553 .or_else(|| existing.as_ref().and_then(|m| m.branch.clone())),
554 author: input
555 .author
556 .or_else(|| existing.as_ref().and_then(|m| m.author.clone())),
557 message: input
558 .message
559 .or_else(|| existing.as_ref().and_then(|m| m.message.clone())),
560 tag: input
561 .tag
562 .or_else(|| existing.as_ref().and_then(|m| m.tag.clone())),
563 tags: if input.tags.is_empty() {
566 existing.map(|m| m.tags).unwrap_or_default()
567 } else {
568 input.tags
569 },
570 };
571 self.kv
572 .write_batch(vec![
573 WriteOp::Put(keys::manifest(&id), manifest.to_bytes()?),
574 WriteOp::Put(keys::meta(&id), serde_json::to_vec(&meta)?),
575 ])
576 .await?;
577 Ok(id)
578 }
579
580 pub async fn get_meta(&self, id: &str) -> Result<Option<DeployMeta>, DeployError> {
582 match self.kv.get(&keys::meta(id)).await? {
583 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
584 None => Ok(None),
585 }
586 }
587
588 pub async fn get_manifest(&self, id: &str) -> Result<Option<Manifest>, DeployError> {
590 match self.kv.get(&keys::manifest(id)).await? {
591 Some(bytes) => Ok(Some(Manifest::from_bytes(&bytes)?)),
592 None => Ok(None),
593 }
594 }
595
596 pub async fn resolve_manifest_id(&self, prefix: &str) -> Result<Option<String>, DeployError> {
603 if self.kv.get(&keys::manifest(prefix)).await?.is_some() {
605 return Ok(Some(prefix.to_string()));
606 }
607 let key_prefix = keys::manifest(prefix);
608 let strip = "manifests/".len();
609 let keys = self.kv.list_prefix(&key_prefix).await?;
610 let mut ids = keys.iter().map(|key| &key[strip..]);
611 match (ids.next(), ids.next()) {
612 (Some(only), None) => Ok(Some(only.to_string())),
613 (Some(_), Some(_)) => Err(DeployError::Ambiguous(prefix.to_string())),
614 _ => Ok(None),
615 }
616 }
617
618 pub async fn has_blob(&self, hash: &str) -> Result<bool, DeployError> {
620 match self.storage.head(&keys::blob(hash)).await {
621 Ok(_) => Ok(true),
622 Err(StorageError::NotFound(_)) => Ok(false),
623 Err(err) => Err(err.into()),
624 }
625 }
626
627 pub async fn missing_blobs(&self, manifest: &Manifest) -> Result<Vec<String>, DeployError> {
629 let mut missing = Vec::new();
630 for hash in manifest.blob_hashes() {
631 if !self.has_blob(&hash).await? {
632 missing.push(hash);
633 }
634 }
635 Ok(missing)
636 }
637
638 pub async fn put_blob(&self, hash: &str, body: ByteStream) -> Result<(), DeployError> {
643 let hasher = Arc::new(Mutex::new(Sha256::new()));
644 let tap = hasher.clone();
645 let verified: ByteStream = body
646 .map(move |chunk| {
647 if let Ok(bytes) = &chunk {
648 tap.lock().unwrap().update(bytes);
649 }
650 chunk
651 })
652 .boxed();
653
654 let key = keys::blob(hash);
655 self.storage.put(&key, verified, PutMeta::default()).await?;
656
657 let actual = hex::encode(hasher.lock().unwrap().clone().finalize());
658 if actual != hash {
659 let _ = self.storage.delete(&key).await;
660 return Err(DeployError::HashMismatch {
661 expected: hash.to_string(),
662 actual,
663 });
664 }
665 Ok(())
666 }
667
668 pub async fn open_blob(&self, hash: &str) -> Result<GetObject, DeployError> {
670 Ok(self.storage.get(&keys::blob(hash)).await?)
671 }
672
673 pub fn blob_file(&self, hash: &str) -> Option<std::fs::File> {
678 self.storage.local_file(&keys::blob(hash))
679 }
680
681 pub async fn open_blob_cached(&self, hash: &str, size: u64) -> Result<BlobBody, DeployError> {
688 use futures::TryStreamExt;
689 if size > SMALL_BLOB_CACHE_MAX {
690 if let Some(bytes) = self.storage.mapped(&keys::blob(hash)) {
695 return Ok(BlobBody::Mapped(bytes));
696 }
697 return Ok(BlobBody::Stream(self.open_blob(hash).await?));
698 }
699 if let Some(bytes) = self.blob_body_cache.read().unwrap().map.get(hash).cloned() {
700 return Ok(BlobBody::Cached(bytes));
701 }
702 let object = self.storage.get(&keys::blob(hash)).await?;
705 let mut buf = bytes::BytesMut::with_capacity(size as usize);
706 let mut body = object.body;
707 while let Some(chunk) = body.try_next().await? {
708 buf.extend_from_slice(&chunk);
709 }
710 let bytes = buf.freeze();
711 {
712 let mut cache = self.blob_body_cache.write().unwrap();
713 if cache.bytes.saturating_add(bytes.len()) > BLOB_BODY_CACHE_MAX_BYTES {
715 cache.map.clear();
716 cache.bytes = 0;
717 }
718 if cache.map.insert(hash.to_string(), bytes.clone()).is_none() {
719 cache.bytes = cache.bytes.saturating_add(bytes.len());
720 }
721 }
722 Ok(BlobBody::Cached(bytes))
723 }
724
725 pub async fn open_blob_range(
727 &self,
728 hash: &str,
729 offset: u64,
730 len: Option<u64>,
731 ) -> Result<GetObject, DeployError> {
732 Ok(self
733 .storage
734 .get_range(&keys::blob(hash), offset, len)
735 .await?)
736 }
737
738 pub async fn get_site_config(
741 &self,
742 project: ProjectRef<'_>,
743 site: &str,
744 ) -> Result<Option<SiteConfig>, DeployError> {
745 let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
746 return Ok(None);
747 };
748 let hash = String::from_utf8_lossy(&hash).into_owned();
749 match self.kv.get(&keys::site_config_blob(&hash)).await? {
750 Some(bytes) => Ok(Some(SiteConfig::from_json(&bytes)?)),
751 None => Ok(None),
753 }
754 }
755
756 pub async fn get_site_config_cached(
763 &self,
764 project: ProjectRef<'_>,
765 site: &str,
766 ) -> Result<Option<Arc<SiteConfig>>, DeployError> {
767 let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
768 return Ok(None);
769 };
770 let hash = String::from_utf8_lossy(&hash).into_owned();
771 if let Some(cfg) = self.site_config_cache.read().unwrap().get(&hash).cloned() {
772 return Ok(Some(cfg));
773 }
774 let Some(bytes) = self.kv.get(&keys::site_config_blob(&hash)).await? else {
775 return Ok(None); };
777 let cfg = Arc::new(SiteConfig::from_json(&bytes)?);
778 {
779 let mut cache = self.site_config_cache.write().unwrap();
780 if cache.len() >= 512 {
784 cache.clear();
785 }
786 cache.insert(hash, Arc::clone(&cfg));
787 }
788 Ok(Some(cfg))
789 }
790
791 pub async fn set_site_config(
811 &self,
812 project: ProjectRef<'_>,
813 site: &str,
814 config: &SiteConfig,
815 ) -> Result<(), DeployError> {
816 let _claim = self.domain_claim_lock.lock().await;
817 self.set_site_config_locked(project, site, config).await
818 }
819
820 async fn set_site_config_locked(
825 &self,
826 project: ProjectRef<'_>,
827 site: &str,
828 config: &SiteConfig,
829 ) -> Result<(), DeployError> {
830 let owner = DomainOwner::new(project.as_str(), site);
831 for host in config.domains.exact_hosts() {
835 self.ensure_host_claimable(&keys::domain(host), host, &owner)
836 .await?;
837 }
838 for wildcard in &config.domains.wildcards {
839 if let Some(suffix) = wildcard.strip_prefix("*.") {
840 self.ensure_host_claimable(&keys::wildcard(suffix), wildcard, &owner)
841 .await?;
842 }
843 }
844
845 let body = config.to_json()?;
846 let hash = sha256_hex(&body);
847
848 let mut ops = Vec::new();
849 if let Some(old) = self.get_site_config(project, site).await? {
850 for host in old.domains.exact_hosts() {
851 ops.push(WriteOp::Delete(keys::domain(host)));
852 }
853 for wildcard in &old.domains.wildcards {
854 if let Some(suffix) = wildcard.strip_prefix("*.") {
855 ops.push(WriteOp::Delete(keys::wildcard(suffix)));
856 }
857 }
858 }
859
860 ops.push(WriteOp::Put(keys::site_config_blob(&hash), body));
862 ops.push(WriteOp::Put(
863 keys::site_pointer(project, site),
864 hash.into_bytes(),
865 ));
866
867 let contexts = &config.domains.contexts;
873 let primary_ctx = config
874 .domains
875 .primary
876 .as_deref()
877 .and_then(|p| contexts.get(p));
878 let owner_for = |key: &str| -> Vec<u8> {
879 let ctx = contexts.get(key).or(primary_ctx);
880 match ctx {
881 Some(tag) => owner.clone().with_context(tag.clone()).to_bytes(),
882 None => owner.to_bytes(),
883 }
884 };
885 for host in config.domains.exact_hosts() {
886 ops.push(WriteOp::Put(keys::domain(host), owner_for(host)));
887 }
888 for wildcard in &config.domains.wildcards {
889 if let Some(suffix) = wildcard.strip_prefix("*.") {
890 let bytes = match contexts.get(wildcard) {
892 Some(tag) => owner.clone().with_context(tag.clone()).to_bytes(),
893 None => owner.to_bytes(),
894 };
895 ops.push(WriteOp::Put(keys::wildcard(suffix), bytes));
896 }
897 }
898 self.kv.write_batch(ops).await?;
899 self.bump_domain_epoch();
902 Ok(())
903 }
904
905 async fn ensure_host_claimable(
911 &self,
912 key: &str,
913 label: &str,
914 owner: &DomainOwner,
915 ) -> Result<(), DeployError> {
916 if let Some(bytes) = self.kv.get(key).await? {
917 let held = DomainOwner::from_bytes(&bytes);
918 if &held != owner {
919 return Err(DeployError::Conflict(format!(
920 "{label} is already attached to site `{}` in project `{}`",
921 held.site, held.project
922 )));
923 }
924 }
925 Ok(())
926 }
927
928 pub async fn resolve_site_by_host(
933 &self,
934 host: &str,
935 ) -> Result<Option<DomainOwner>, DeployError> {
936 let host = canon_host(host);
939 let host = host.as_str();
940 let epoch = self.domain_epoch.load(std::sync::atomic::Ordering::Acquire);
944 {
945 let cache = self.domain_cache.read().unwrap();
946 if cache.epoch == epoch {
947 if let Some(owner) = cache.map.get(host) {
948 return Ok(owner.clone());
949 }
950 }
951 }
952 let resolved = self.resolve_site_by_host_uncached(host).await?;
953 {
957 let mut cache = self.domain_cache.write().unwrap();
958 let current = self.domain_epoch.load(std::sync::atomic::Ordering::Acquire);
959 if cache.epoch != current {
960 cache.epoch = current;
961 cache.map.clear();
962 }
963 if current == epoch {
964 if cache.map.len() >= 4096 {
968 cache.map.clear();
969 }
970 cache.map.insert(host.to_string(), resolved.clone());
971 }
972 }
973 Ok(resolved)
974 }
975
976 async fn resolve_site_by_host_uncached(
980 &self,
981 host: &str,
982 ) -> Result<Option<DomainOwner>, DeployError> {
983 if let Some(bytes) = self.kv.get(&keys::domain(host)).await? {
984 return Ok(Some(DomainOwner::from_bytes(&bytes)));
985 }
986 let mut rest = host;
987 while let Some((_, parent)) = rest.split_once('.') {
988 if let Some(bytes) = self.kv.get(&keys::wildcard(parent)).await? {
989 return Ok(Some(DomainOwner::from_bytes(&bytes)));
990 }
991 rest = parent;
992 }
993 Ok(None)
994 }
995
996 fn bump_domain_epoch(&self) {
999 self.domain_epoch
1000 .fetch_add(1, std::sync::atomic::Ordering::Release);
1001 }
1002
1003 pub async fn all_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
1009 let mut sites = BTreeSet::new();
1010 for prefix in [
1011 keys::current_prefix(project),
1012 keys::site_prefix(project),
1013 keys::history_prefix(project),
1014 ] {
1015 for key in self.kv.list_prefix(&prefix).await? {
1016 if let Some(name) = key.strip_prefix(&prefix) {
1017 if !name.is_empty() {
1018 sites.insert(name.to_string());
1019 }
1020 }
1021 }
1022 }
1023 Ok(sites.into_iter().collect())
1024 }
1025
1026 pub async fn all_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
1030 let mut out = Vec::new();
1031 for project in self.discover_projects().await? {
1032 for site in self.all_sites(ProjectRef::new(&project)).await? {
1033 out.push((project.clone(), site));
1034 }
1035 }
1036 Ok(out)
1037 }
1038
1039 pub async fn discover_projects(&self) -> Result<Vec<String>, DeployError> {
1044 let mut projects = BTreeSet::new();
1045 for key in self.kv.list_prefix(keys::PROJECT_ROOT).await? {
1046 if let Some(p) = keys::project_of_key(&key) {
1047 projects.insert(p.to_string());
1048 }
1049 }
1050 Ok(projects.into_iter().collect())
1051 }
1052
1053 pub async fn put_function(
1061 &self,
1062 project: ProjectRef<'_>,
1063 f: &crate::function::Function,
1064 ) -> Result<(), DeployError> {
1065 let bytes = serde_json::to_vec(f).map_err(|e| DeployError::Serde(e.to_string()))?;
1066 self.kv
1067 .put(
1068 &crate::function::keys::meta(project.as_str(), &f.name),
1069 bytes,
1070 )
1071 .await?;
1072 Ok(())
1073 }
1074
1075 pub async fn get_function(
1077 &self,
1078 project: ProjectRef<'_>,
1079 name: &str,
1080 ) -> Result<Option<crate::function::Function>, DeployError> {
1081 match self
1082 .kv
1083 .get(&crate::function::keys::meta(project.as_str(), name))
1084 .await?
1085 {
1086 Some(bytes) => Ok(Some(
1087 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1088 )),
1089 None => Ok(None),
1090 }
1091 }
1092
1093 pub async fn list_stored_functions(
1099 &self,
1100 project: ProjectRef<'_>,
1101 ) -> Result<Vec<crate::function::Function>, DeployError> {
1102 let prefix = crate::function::keys::functions_prefix(project.as_str());
1103 let mut out = Vec::new();
1104 for key in self.kv.list_prefix(&prefix).await? {
1105 if key[prefix.len()..].contains('/') {
1106 continue;
1107 }
1108 if let Some(bytes) = self.kv.get(&key).await? {
1109 if let Ok(f) = serde_json::from_slice(&bytes) {
1110 out.push(f);
1111 }
1112 }
1113 }
1114 Ok(out)
1115 }
1116
1117 pub async fn delete_function(
1120 &self,
1121 project: ProjectRef<'_>,
1122 name: &str,
1123 ) -> Result<bool, DeployError> {
1124 let meta = crate::function::keys::meta(project.as_str(), name);
1125 let existed = self.kv.get(&meta).await?.is_some();
1126 for key in self.kv.list_prefix(&format!("{meta}/")).await? {
1133 self.kv.delete(&key).await?;
1134 }
1135 self.kv.delete(&meta).await?;
1136 self.kv
1137 .delete(&crate::function::keys::metering(project.as_str(), name))
1138 .await?;
1139 Ok(existed)
1140 }
1141
1142 pub async fn put_trigger(
1146 &self,
1147 project: ProjectRef<'_>,
1148 function: &str,
1149 trigger: &crate::function::FunctionTrigger,
1150 ) -> Result<(), DeployError> {
1151 let bytes = serde_json::to_vec(trigger).map_err(|e| DeployError::Serde(e.to_string()))?;
1152 self.kv
1153 .put(
1154 &crate::function::keys::trigger(project.as_str(), function, &trigger.id),
1155 bytes,
1156 )
1157 .await?;
1158 Ok(())
1159 }
1160
1161 pub async fn get_trigger(
1163 &self,
1164 project: ProjectRef<'_>,
1165 function: &str,
1166 id: &str,
1167 ) -> Result<Option<crate::function::FunctionTrigger>, DeployError> {
1168 match self
1169 .kv
1170 .get(&crate::function::keys::trigger(
1171 project.as_str(),
1172 function,
1173 id,
1174 ))
1175 .await?
1176 {
1177 Some(bytes) => Ok(Some(
1178 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1179 )),
1180 None => Ok(None),
1181 }
1182 }
1183
1184 pub async fn list_triggers(
1186 &self,
1187 project: ProjectRef<'_>,
1188 function: &str,
1189 ) -> Result<Vec<crate::function::FunctionTrigger>, DeployError> {
1190 let prefix = crate::function::keys::triggers_prefix(project.as_str(), function);
1191 let mut out = Vec::new();
1192 for key in self.kv.list_prefix(&prefix).await? {
1193 if let Some(bytes) = self.kv.get(&key).await? {
1194 if let Ok(t) = serde_json::from_slice(&bytes) {
1195 out.push(t);
1196 }
1197 }
1198 }
1199 Ok(out)
1200 }
1201
1202 pub async fn delete_trigger(
1204 &self,
1205 project: ProjectRef<'_>,
1206 function: &str,
1207 id: &str,
1208 ) -> Result<bool, DeployError> {
1209 let key = crate::function::keys::trigger(project.as_str(), function, id);
1210 let existed = self.kv.get(&key).await?.is_some();
1211 self.kv.delete(&key).await?;
1212 Ok(existed)
1213 }
1214
1215 pub async fn put_invocation(
1219 &self,
1220 project: ProjectRef<'_>,
1221 inv: &crate::function::Invocation,
1222 ) -> Result<(), DeployError> {
1223 let bytes = serde_json::to_vec(inv).map_err(|e| DeployError::Serde(e.to_string()))?;
1224 self.kv
1225 .put(
1226 &crate::function::keys::invocation(project.as_str(), &inv.function, &inv.id),
1227 bytes,
1228 )
1229 .await?;
1230 Ok(())
1231 }
1232
1233 pub async fn get_invocation(
1235 &self,
1236 project: ProjectRef<'_>,
1237 function: &str,
1238 id: &str,
1239 ) -> Result<Option<crate::function::Invocation>, DeployError> {
1240 match self
1241 .kv
1242 .get(&crate::function::keys::invocation(
1243 project.as_str(),
1244 function,
1245 id,
1246 ))
1247 .await?
1248 {
1249 Some(bytes) => Ok(Some(
1250 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1251 )),
1252 None => Ok(None),
1253 }
1254 }
1255
1256 pub async fn list_invocations(
1258 &self,
1259 project: ProjectRef<'_>,
1260 function: &str,
1261 ) -> Result<Vec<crate::function::Invocation>, DeployError> {
1262 let prefix = crate::function::keys::invocations_prefix(project.as_str(), function);
1263 let mut out = Vec::new();
1264 for key in self.kv.list_prefix(&prefix).await? {
1265 if let Some(bytes) = self.kv.get(&key).await? {
1266 if let Ok(inv) = serde_json::from_slice(&bytes) {
1267 out.push(inv);
1268 }
1269 }
1270 }
1271 Ok(out)
1272 }
1273
1274 pub async fn put_idempotency(
1277 &self,
1278 project: ProjectRef<'_>,
1279 function: &str,
1280 key: &str,
1281 invocation_id: &str,
1282 ) -> Result<(), DeployError> {
1283 self.kv
1284 .put(
1285 &crate::function::keys::idempotency(project.as_str(), function, key),
1286 invocation_id.as_bytes().to_vec(),
1287 )
1288 .await?;
1289 Ok(())
1290 }
1291
1292 pub async fn get_idempotency(
1294 &self,
1295 project: ProjectRef<'_>,
1296 function: &str,
1297 key: &str,
1298 ) -> Result<Option<String>, DeployError> {
1299 match self
1300 .kv
1301 .get(&crate::function::keys::idempotency(
1302 project.as_str(),
1303 function,
1304 key,
1305 ))
1306 .await?
1307 {
1308 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1309 None => Ok(None),
1310 }
1311 }
1312
1313 pub async fn get_metering(
1317 &self,
1318 project: ProjectRef<'_>,
1319 function: &str,
1320 ) -> Result<Option<crate::function::Metering>, DeployError> {
1321 match self
1322 .kv
1323 .get(&crate::function::keys::metering(project.as_str(), function))
1324 .await?
1325 {
1326 Some(bytes) => Ok(Some(
1327 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1328 )),
1329 None => Ok(None),
1330 }
1331 }
1332
1333 pub async fn put_metering(
1335 &self,
1336 project: ProjectRef<'_>,
1337 metering: &crate::function::Metering,
1338 ) -> Result<(), DeployError> {
1339 let bytes = serde_json::to_vec(metering).map_err(|e| DeployError::Serde(e.to_string()))?;
1340 self.kv
1341 .put(
1342 &crate::function::keys::metering(project.as_str(), &metering.function),
1343 bytes,
1344 )
1345 .await?;
1346 Ok(())
1347 }
1348
1349 pub async fn list_metering(
1352 &self,
1353 project: ProjectRef<'_>,
1354 ) -> Result<Vec<crate::function::Metering>, DeployError> {
1355 let prefix = crate::function::keys::metering_prefix(project.as_str());
1356 let mut out = Vec::new();
1357 for key in self.kv.list_prefix(&prefix).await? {
1358 if let Some(bytes) = self.kv.get(&key).await? {
1359 if let Ok(m) = serde_json::from_slice(&bytes) {
1360 out.push(m);
1361 }
1362 }
1363 }
1364 Ok(out)
1365 }
1366
1367 pub async fn put_managed_notification(
1372 &self,
1373 project: ProjectRef<'_>,
1374 record: &crate::blob_notify::ManagedNotification,
1375 ) -> Result<(), DeployError> {
1376 let bytes = record
1377 .to_json()
1378 .map_err(|e| DeployError::Serde(e.to_string()))?;
1379 self.kv
1380 .put(
1381 &crate::blob_notify::blobnotify_key(
1382 project.as_str(),
1383 &record.function,
1384 &record.prefix,
1385 ),
1386 bytes,
1387 )
1388 .await?;
1389 Ok(())
1390 }
1391
1392 pub async fn get_managed_notification(
1394 &self,
1395 project: ProjectRef<'_>,
1396 function: &str,
1397 prefix: &str,
1398 ) -> Result<Option<crate::blob_notify::ManagedNotification>, DeployError> {
1399 match self
1400 .kv
1401 .get(&crate::blob_notify::blobnotify_key(
1402 project.as_str(),
1403 function,
1404 prefix,
1405 ))
1406 .await?
1407 {
1408 Some(bytes) => Ok(Some(
1409 crate::blob_notify::ManagedNotification::from_json(&bytes)
1410 .map_err(|e| DeployError::Serde(e.to_string()))?,
1411 )),
1412 None => Ok(None),
1413 }
1414 }
1415
1416 pub async fn list_managed_notifications(
1418 &self,
1419 project: ProjectRef<'_>,
1420 function: &str,
1421 ) -> Result<Vec<crate::blob_notify::ManagedNotification>, DeployError> {
1422 let prefix = crate::blob_notify::blobnotify_function_prefix(project.as_str(), function);
1423 let mut out = Vec::new();
1424 for key in self.kv.list_prefix(&prefix).await? {
1425 if let Some(bytes) = self.kv.get(&key).await? {
1426 if let Ok(record) = crate::blob_notify::ManagedNotification::from_json(&bytes) {
1427 out.push(record);
1428 }
1429 }
1430 }
1431 Ok(out)
1432 }
1433
1434 pub async fn remove_managed_notification(
1436 &self,
1437 project: ProjectRef<'_>,
1438 function: &str,
1439 prefix: &str,
1440 ) -> Result<(), DeployError> {
1441 self.kv
1442 .delete(&crate::blob_notify::blobnotify_key(
1443 project.as_str(),
1444 function,
1445 prefix,
1446 ))
1447 .await?;
1448 Ok(())
1449 }
1450
1451 pub async fn put_workflow(
1455 &self,
1456 project: ProjectRef<'_>,
1457 workflow: &crate::workflow::Workflow,
1458 ) -> Result<(), DeployError> {
1459 let bytes = serde_json::to_vec(workflow).map_err(|e| DeployError::Serde(e.to_string()))?;
1460 self.kv
1461 .put(
1462 &crate::workflow::keys::definition(project.as_str(), &workflow.name),
1463 bytes,
1464 )
1465 .await?;
1466 Ok(())
1467 }
1468
1469 pub async fn get_workflow(
1471 &self,
1472 project: ProjectRef<'_>,
1473 name: &str,
1474 ) -> Result<Option<crate::workflow::Workflow>, DeployError> {
1475 match self
1476 .kv
1477 .get(&crate::workflow::keys::definition(project.as_str(), name))
1478 .await?
1479 {
1480 Some(bytes) => Ok(Some(
1481 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1482 )),
1483 None => Ok(None),
1484 }
1485 }
1486
1487 pub async fn list_workflows(
1491 &self,
1492 project: ProjectRef<'_>,
1493 ) -> Result<Vec<crate::workflow::Workflow>, DeployError> {
1494 let prefix = crate::workflow::keys::definitions_prefix(project.as_str());
1495 let mut out = Vec::new();
1496 for key in self.kv.list_prefix(&prefix).await? {
1497 if key[prefix.len()..].contains('/') {
1498 continue;
1499 }
1500 if let Some(bytes) = self.kv.get(&key).await? {
1501 if let Ok(w) = serde_json::from_slice(&bytes) {
1502 out.push(w);
1503 }
1504 }
1505 }
1506 Ok(out)
1507 }
1508
1509 pub async fn delete_workflow(
1512 &self,
1513 project: ProjectRef<'_>,
1514 name: &str,
1515 ) -> Result<bool, DeployError> {
1516 let key = crate::workflow::keys::definition(project.as_str(), name);
1517 let existed = self.kv.get(&key).await?.is_some();
1518 self.kv.delete(&key).await?;
1519 Ok(existed)
1520 }
1521
1522 pub async fn put_workflow_run(
1524 &self,
1525 project: ProjectRef<'_>,
1526 run: &crate::workflow::WorkflowRun,
1527 ) -> Result<(), DeployError> {
1528 let bytes = serde_json::to_vec(run).map_err(|e| DeployError::Serde(e.to_string()))?;
1529 self.kv
1530 .put(
1531 &crate::workflow::keys::run(project.as_str(), &run.workflow, &run.id),
1532 bytes,
1533 )
1534 .await?;
1535 Ok(())
1536 }
1537
1538 pub async fn get_workflow_run(
1540 &self,
1541 project: ProjectRef<'_>,
1542 workflow: &str,
1543 id: &str,
1544 ) -> Result<Option<crate::workflow::WorkflowRun>, DeployError> {
1545 match self
1546 .kv
1547 .get(&crate::workflow::keys::run(project.as_str(), workflow, id))
1548 .await?
1549 {
1550 Some(bytes) => Ok(Some(
1551 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1552 )),
1553 None => Ok(None),
1554 }
1555 }
1556
1557 pub async fn list_workflow_runs(
1559 &self,
1560 project: ProjectRef<'_>,
1561 workflow: &str,
1562 ) -> Result<Vec<crate::workflow::WorkflowRun>, DeployError> {
1563 let prefix = crate::workflow::keys::runs_prefix(project.as_str(), workflow);
1564 let mut out = Vec::new();
1565 for key in self.kv.list_prefix(&prefix).await? {
1566 if let Some(bytes) = self.kv.get(&key).await? {
1567 if let Ok(run) = serde_json::from_slice(&bytes) {
1568 out.push(run);
1569 }
1570 }
1571 }
1572 Ok(out)
1573 }
1574
1575 pub async fn get_domain_verification(
1577 &self,
1578 project: ProjectRef<'_>,
1579 site: &SiteName,
1580 host: &str,
1581 ) -> Result<Option<DomainVerification>, DeployError> {
1582 match self
1583 .kv
1584 .get(&keys::domain_verification(project, site.as_str(), host))
1585 .await?
1586 {
1587 Some(bytes) => Ok(Some(DomainVerification::from_json(&bytes)?)),
1588 None => Ok(None),
1589 }
1590 }
1591
1592 pub async fn list_domain_verifications(
1594 &self,
1595 project: ProjectRef<'_>,
1596 site: &SiteName,
1597 ) -> Result<Vec<DomainVerification>, DeployError> {
1598 let prefix = keys::domain_verification_prefix(project, site.as_str());
1599 let mut out = Vec::new();
1600 for key in self.kv.list_prefix(&prefix).await? {
1601 if let Some(bytes) = self.kv.get(&key).await? {
1602 out.push(DomainVerification::from_json(&bytes)?);
1603 }
1604 }
1605 out.sort_by(|a, b| a.host.cmp(&b.host));
1606 Ok(out)
1607 }
1608
1609 pub async fn list_all_domain_verifications(
1616 &self,
1617 ) -> Result<Vec<(String, String, DomainVerification)>, DeployError> {
1618 let mut out = Vec::new();
1619 for project in self.discover_projects().await? {
1620 let pref = ProjectRef::new(&project);
1621 let scan = keys::domain_verification_project_prefix(pref);
1622 for key in self.kv.list_prefix(&scan).await? {
1623 let Some((site, _host)) = key
1626 .strip_prefix(&scan)
1627 .and_then(|rest| rest.split_once('/'))
1628 else {
1629 continue;
1630 };
1631 if let Some(bytes) = self.kv.get(&key).await? {
1632 out.push((
1633 project.clone(),
1634 site.to_string(),
1635 DomainVerification::from_json(&bytes)?,
1636 ));
1637 }
1638 }
1639 }
1640 Ok(out)
1641 }
1642
1643 pub async fn find_pending_http_challenge(
1652 &self,
1653 host: &str,
1654 token: &str,
1655 now_unix: u64,
1656 ) -> Result<Option<DomainVerification>, DeployError> {
1657 let host = crate::domain_verify::normalize_host(host);
1658 let Some(owner_bytes) = self
1663 .kv
1664 .get(&keys::http_challenge_index(&host, token))
1665 .await?
1666 else {
1667 return Ok(None);
1668 };
1669 let owner = DomainOwner::from_bytes(&owner_bytes);
1670 let site = SiteName::new(owner.site);
1671 let Some(v) = self
1672 .get_domain_verification(ProjectRef::new(&owner.project), &site, &host)
1673 .await?
1674 else {
1675 return Ok(None);
1676 };
1677 if v.method == VerificationMethod::Http
1678 && v.host == host
1679 && v.matches(token)
1680 && !v.is_expired(now_unix)
1681 {
1682 Ok(Some(v))
1683 } else {
1684 Ok(None)
1685 }
1686 }
1687
1688 async fn put_domain_verification(
1689 &self,
1690 project: ProjectRef<'_>,
1691 site: &SiteName,
1692 verification: &DomainVerification,
1693 ) -> Result<(), DeployError> {
1694 let mut ops = vec![WriteOp::Put(
1695 keys::domain_verification(project, site.as_str(), &verification.host),
1696 verification.to_json()?,
1697 )];
1698 if verification.method == VerificationMethod::Http {
1704 ops.push(WriteOp::Put(
1705 keys::http_challenge_index(&verification.host, &verification.token),
1706 DomainOwner::new(project.as_str(), site.as_str()).to_bytes(),
1707 ));
1708 }
1709 self.kv.write_batch(ops).await?;
1710 Ok(())
1711 }
1712
1713 pub async fn get_managed_dns(
1717 &self,
1718 project: ProjectRef<'_>,
1719 site: &SiteName,
1720 host: &str,
1721 ) -> Result<Option<crate::dns_managed::ManagedDns>, DeployError> {
1722 match self
1723 .kv
1724 .get(&crate::dns_managed::dnsmanaged_key(
1725 project.as_str(),
1726 site.as_str(),
1727 host,
1728 ))
1729 .await?
1730 {
1731 Some(bytes) => Ok(Some(crate::dns_managed::ManagedDns::from_json(&bytes)?)),
1732 None => Ok(None),
1733 }
1734 }
1735
1736 pub async fn set_managed_dns(
1738 &self,
1739 project: ProjectRef<'_>,
1740 site: &SiteName,
1741 ledger: &crate::dns_managed::ManagedDns,
1742 ) -> Result<(), DeployError> {
1743 self.kv
1744 .put(
1745 &crate::dns_managed::dnsmanaged_key(project.as_str(), site.as_str(), &ledger.host),
1746 ledger.to_json()?,
1747 )
1748 .await?;
1749 Ok(())
1750 }
1751
1752 pub async fn remove_managed_dns(
1754 &self,
1755 project: ProjectRef<'_>,
1756 site: &SiteName,
1757 host: &str,
1758 ) -> Result<(), DeployError> {
1759 self.kv
1760 .delete(&crate::dns_managed::dnsmanaged_key(
1761 project.as_str(),
1762 site.as_str(),
1763 host,
1764 ))
1765 .await?;
1766 Ok(())
1767 }
1768
1769 pub async fn list_managed_dns(
1772 &self,
1773 project: ProjectRef<'_>,
1774 site: &SiteName,
1775 ) -> Result<Vec<crate::dns_managed::ManagedDns>, DeployError> {
1776 let prefix = crate::dns_managed::dnsmanaged_site_prefix(project.as_str(), site.as_str());
1777 let mut out = Vec::new();
1778 for key in self.kv.list_prefix(&prefix).await? {
1779 if let Some(bytes) = self.kv.get(&key).await? {
1780 out.push(crate::dns_managed::ManagedDns::from_json(&bytes)?);
1781 }
1782 }
1783 out.sort_by(|a, b| a.host.cmp(&b.host));
1784 Ok(out)
1785 }
1786
1787 pub async fn start_domain_verification(
1794 &self,
1795 project: ProjectRef<'_>,
1796 site: &SiteName,
1797 host: &str,
1798 method: VerificationMethod,
1799 now_unix: u64,
1800 ) -> Result<DomainVerification, DeployError> {
1801 if let Some(existing) = self.get_domain_verification(project, site, host).await? {
1802 if existing.verified || existing.method == method {
1803 return Ok(existing);
1804 }
1805 }
1806 const MAX_PENDING_VERIFICATIONS_PER_SITE: usize = 64;
1813 let pending = self
1814 .list_domain_verifications(project, site)
1815 .await?
1816 .into_iter()
1817 .filter(|v| !v.verified && !v.is_expired(now_unix))
1818 .count();
1819 if pending >= MAX_PENDING_VERIFICATIONS_PER_SITE {
1820 return Err(DeployError::Conflict(format!(
1821 "too many pending domain verifications for site {site} \
1822 (max {MAX_PENDING_VERIFICATIONS_PER_SITE}); verify or remove some first"
1823 )));
1824 }
1825 let verification = DomainVerification::new(host, method, now_unix);
1826 self.put_domain_verification(project, site, &verification)
1827 .await?;
1828 Ok(verification)
1829 }
1830
1831 pub async fn is_domain_verified(
1833 &self,
1834 project: ProjectRef<'_>,
1835 site: &SiteName,
1836 host: &str,
1837 ) -> Result<bool, DeployError> {
1838 Ok(self
1839 .get_domain_verification(project, site, host)
1840 .await?
1841 .is_some_and(|v| v.verified))
1842 }
1843
1844 pub async fn mark_domain_verified(
1847 &self,
1848 project: ProjectRef<'_>,
1849 site: &SiteName,
1850 host: &str,
1851 ) -> Result<DomainVerification, DeployError> {
1852 let mut verification = self
1853 .get_domain_verification(project, site, host)
1854 .await?
1855 .ok_or_else(|| {
1856 DeployError::NotFound(format!("no verification challenge for {host}"))
1857 })?;
1858 verification.verified = true;
1859 self.put_domain_verification(project, site, &verification)
1860 .await?;
1861 Ok(verification)
1862 }
1863
1864 pub async fn remove_domain_verification(
1867 &self,
1868 project: ProjectRef<'_>,
1869 site: &SiteName,
1870 host: &str,
1871 ) -> Result<bool, DeployError> {
1872 let Some(v) = self.get_domain_verification(project, site, host).await? else {
1873 return Ok(false);
1874 };
1875 let mut ops = vec![WriteOp::Delete(keys::domain_verification(
1876 project,
1877 site.as_str(),
1878 host,
1879 ))];
1880 if v.method == VerificationMethod::Http {
1881 ops.push(WriteOp::Delete(keys::http_challenge_index(
1882 &v.host, &v.token,
1883 )));
1884 }
1885 self.kv.write_batch(ops).await?;
1886 Ok(true)
1887 }
1888
1889 pub async fn attach_verified_domain(
1900 &self,
1901 project: ProjectRef<'_>,
1902 site: &SiteName,
1903 host: &str,
1904 ) -> Result<SiteConfig, DeployError> {
1905 if !self.is_domain_verified(project, site, host).await? {
1906 return Err(DeployError::NotFound(format!(
1907 "{host} is not verified for {site}; run domain verification first"
1908 )));
1909 }
1910 if host.trim_start().starts_with("*.")
1914 && self
1915 .get_domain_verification(project, site, host)
1916 .await?
1917 .map(|v| v.method)
1918 != Some(VerificationMethod::Dns)
1919 {
1920 return Err(DeployError::Conflict(format!(
1921 "wildcard {host} must be verified via DNS \
1922 (an HTTP token proves only the base host, not the subtree)"
1923 )));
1924 }
1925 let _claim = self.domain_claim_lock.lock().await;
1929 let mut config = self
1930 .get_site_config(project, site.as_str())
1931 .await?
1932 .unwrap_or_default();
1933 let domains = &mut config.domains;
1934 if let Some(suffix) = host.strip_prefix("*.") {
1935 let wildcard = format!("*.{}", suffix.trim_end_matches('.').to_ascii_lowercase());
1936 if !domains.wildcards.contains(&wildcard) {
1937 domains.wildcards.push(wildcard);
1938 }
1939 } else {
1940 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
1941 if domains.primary.is_none() {
1942 domains.primary = Some(host);
1943 } else if domains.primary.as_deref() != Some(host.as_str())
1944 && !domains.aliases.contains(&host)
1945 {
1946 domains.aliases.push(host);
1947 }
1948 }
1949 self.set_site_config_locked(project, site.as_str(), &config)
1950 .await?;
1951 Ok(config)
1952 }
1953
1954 pub async fn set_alias(
1960 &self,
1961 project: ProjectRef<'_>,
1962 site: &str,
1963 name: &str,
1964 id: &str,
1965 ) -> Result<(), DeployError> {
1966 let manifest = self
1967 .get_manifest(id)
1968 .await?
1969 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
1970 let missing = self.missing_blobs(&manifest).await?;
1971 if !missing.is_empty() {
1972 return Err(DeployError::Incomplete(missing));
1973 }
1974 self.kv
1975 .put(&keys::alias(project, site, name), id.as_bytes().to_vec())
1976 .await?;
1977 Ok(())
1978 }
1979
1980 pub async fn get_alias(
1982 &self,
1983 project: ProjectRef<'_>,
1984 site: &str,
1985 name: &str,
1986 ) -> Result<Option<String>, DeployError> {
1987 match self.kv.get(&keys::alias(project, site, name)).await? {
1988 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1989 None => Ok(None),
1990 }
1991 }
1992
1993 pub async fn remove_alias(
1995 &self,
1996 project: ProjectRef<'_>,
1997 site: &str,
1998 name: &str,
1999 ) -> Result<bool, DeployError> {
2000 let key = keys::alias(project, site, name);
2001 let existed = self.kv.get(&key).await?.is_some();
2002 if existed {
2003 self.kv.delete(&key).await?;
2004 }
2005 Ok(existed)
2006 }
2007
2008 pub async fn list_aliases(
2010 &self,
2011 project: ProjectRef<'_>,
2012 site: &str,
2013 ) -> Result<BTreeMap<String, String>, DeployError> {
2014 let prefix = keys::alias_prefix(project, site);
2015 let mut out = BTreeMap::new();
2016 for key in self.kv.list_prefix(&prefix).await? {
2017 if let Some(bytes) = self.kv.get(&key).await? {
2018 let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
2019 out.insert(name, String::from_utf8_lossy(&bytes).into_owned());
2020 }
2021 }
2022 Ok(out)
2023 }
2024
2025 pub async fn put_token_meta(&self, meta: &crate::authz::TokenMeta) -> Result<(), DeployError> {
2030 self.kv
2031 .put(
2032 &crate::authz::token_meta_key(&meta.revocation_id),
2033 serde_json::to_vec(meta)?,
2034 )
2035 .await?;
2036 Ok(())
2037 }
2038
2039 pub async fn list_token_meta(&self) -> Result<Vec<crate::authz::TokenMeta>, DeployError> {
2041 let mut out = Vec::new();
2042 for key in self.kv.list_prefix(crate::authz::TOKEN_META_PREFIX).await? {
2043 if let Some(bytes) = self.kv.get(&key).await? {
2044 if let Ok(meta) = serde_json::from_slice::<crate::authz::TokenMeta>(&bytes) {
2045 out.push(meta);
2046 }
2047 }
2048 }
2049 Ok(out)
2050 }
2051
2052 pub async fn revoke_token(&self, id_or_prefix: &str) -> Result<bool, DeployError> {
2057 let ids: Vec<String> = self
2058 .list_token_meta()
2059 .await?
2060 .into_iter()
2061 .map(|m| m.revocation_id)
2062 .collect();
2063 let matches: Vec<&String> = ids
2064 .iter()
2065 .filter(|id| id.starts_with(id_or_prefix))
2066 .collect();
2067 if let [id] = matches.as_slice() {
2068 self.kv
2069 .put(&crate::authz::revoked_key(id), Vec::new())
2070 .await?;
2071 self.kv.delete(&crate::authz::token_meta_key(id)).await?;
2072 Ok(true)
2073 } else {
2074 Ok(false)
2075 }
2076 }
2077
2078 pub async fn bootstrap_consumed(&self, secret_hash: &str) -> Result<bool, DeployError> {
2083 Ok(self
2084 .kv
2085 .get(&crate::authz::bootstrap_key(secret_hash))
2086 .await?
2087 .is_some())
2088 }
2089
2090 pub async fn mark_bootstrap_consumed(&self, secret_hash: &str) -> Result<(), DeployError> {
2092 self.kv
2093 .put(&crate::authz::bootstrap_key(secret_hash), Vec::new())
2094 .await?;
2095 Ok(())
2096 }
2097
2098 pub async fn get_authz_policy(&self) -> Result<Option<crate::authz::AuthzPolicy>, DeployError> {
2101 match self.kv.get(crate::authz::POLICY_KEY).await? {
2102 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2103 None => Ok(None),
2104 }
2105 }
2106
2107 pub async fn set_authz_policy(
2111 &self,
2112 policy: &crate::authz::AuthzPolicy,
2113 ) -> Result<(), DeployError> {
2114 self.kv
2115 .put(crate::authz::POLICY_KEY, serde_json::to_vec(policy)?)
2116 .await?;
2117 Ok(())
2118 }
2119
2120 pub async fn add_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
2125 self.kv
2126 .put(&crate::authz::root_anchor_key(pubkey), Vec::new())
2127 .await?;
2128 Ok(())
2129 }
2130
2131 pub async fn remove_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
2133 self.kv
2134 .delete(&crate::authz::root_anchor_key(pubkey))
2135 .await?;
2136 Ok(())
2137 }
2138
2139 pub async fn list_root_anchors(&self) -> Result<Vec<String>, DeployError> {
2141 Ok(self
2142 .kv
2143 .list_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
2144 .await?
2145 .iter()
2146 .filter_map(|k| {
2147 k.strip_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
2148 .map(String::from)
2149 })
2150 .collect())
2151 }
2152
2153 const DAEMON_CURRENT_KEY: &'static str = "daemon/current";
2157 const DAEMON_HISTORY_KEY: &'static str = "daemon/history";
2159 const DAEMON_HISTORY_MAX: usize = 20;
2161
2162 pub async fn daemon_config_generation(&self) -> Result<Option<String>, DeployError> {
2165 Ok(self
2166 .kv
2167 .get(Self::DAEMON_CURRENT_KEY)
2168 .await?
2169 .map(|b| String::from_utf8_lossy(&b).into_owned()))
2170 }
2171
2172 pub async fn get_daemon_config(
2175 &self,
2176 ) -> Result<Option<crate::daemon_config::DaemonConfig>, DeployError> {
2177 let Some(hash) = self.daemon_config_generation().await? else {
2178 return Ok(None);
2179 };
2180 match self.kv.get(&keys::daemon_config_blob(&hash)).await? {
2181 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2182 None => Ok(None),
2184 }
2185 }
2186
2187 pub async fn daemon_config_history(&self) -> Result<Vec<String>, DeployError> {
2190 match self.kv.get(Self::DAEMON_HISTORY_KEY).await? {
2191 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2192 None => Ok(Vec::new()),
2193 }
2194 }
2195
2196 pub async fn set_daemon_config(
2202 &self,
2203 config: &crate::daemon_config::DaemonConfig,
2204 ) -> Result<String, DeployError> {
2205 let body = serde_json::to_vec(config)?;
2206 let hash = sha256_hex(&body);
2207 let mut history = self.daemon_config_history().await?;
2208 if let Some(current) = self.daemon_config_generation().await? {
2209 if current != hash {
2210 history.push(current);
2211 if history.len() > Self::DAEMON_HISTORY_MAX {
2212 let overflow = history.len() - Self::DAEMON_HISTORY_MAX;
2213 history.drain(0..overflow);
2214 }
2215 }
2216 }
2217 let ops = vec![
2218 WriteOp::Put(keys::daemon_config_blob(&hash), body),
2219 WriteOp::Put(
2220 Self::DAEMON_HISTORY_KEY.to_string(),
2221 serde_json::to_vec(&history)?,
2222 ),
2223 WriteOp::Put(
2224 Self::DAEMON_CURRENT_KEY.to_string(),
2225 hash.clone().into_bytes(),
2226 ),
2227 ];
2228 self.kv.write_batch(ops).await?;
2229 Ok(hash)
2230 }
2231
2232 pub async fn rollback_daemon_config(&self) -> Result<Option<String>, DeployError> {
2237 let mut history = self.daemon_config_history().await?;
2238 let Some(prev) = history.pop() else {
2239 return Ok(None);
2240 };
2241 let ops = vec![
2242 WriteOp::Put(
2243 Self::DAEMON_HISTORY_KEY.to_string(),
2244 serde_json::to_vec(&history)?,
2245 ),
2246 WriteOp::Put(
2247 Self::DAEMON_CURRENT_KEY.to_string(),
2248 prev.clone().into_bytes(),
2249 ),
2250 ];
2251 self.kv.write_batch(ops).await?;
2252 Ok(Some(prev))
2253 }
2254
2255 pub async fn put_compute_spec(
2260 &self,
2261 spec: &crate::compute::ComputeSpec,
2262 ) -> Result<String, DeployError> {
2263 let id = spec.id();
2264 self.kv
2265 .put(&crate::compute::spec_key(&id), serde_json::to_vec(spec)?)
2266 .await?;
2267 Ok(id)
2268 }
2269
2270 pub async fn get_compute_spec(
2272 &self,
2273 hash: &str,
2274 ) -> Result<Option<crate::compute::ComputeSpec>, DeployError> {
2275 match self.kv.get(&crate::compute::spec_key(hash)).await? {
2276 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2277 None => Ok(None),
2278 }
2279 }
2280
2281 pub async fn set_compute_workload(
2285 &self,
2286 project: ProjectRef<'_>,
2287 workload: &crate::compute::ComputeWorkload,
2288 ) -> Result<(), DeployError> {
2289 self.kv
2290 .put(
2291 &crate::compute::workload_key(project.as_str(), &workload.name),
2292 serde_json::to_vec(workload)?,
2293 )
2294 .await?;
2295 Ok(())
2296 }
2297
2298 pub async fn get_compute_workload(
2300 &self,
2301 project: ProjectRef<'_>,
2302 name: &str,
2303 ) -> Result<Option<crate::compute::ComputeWorkload>, DeployError> {
2304 match self
2305 .kv
2306 .get(&crate::compute::workload_key(project.as_str(), name))
2307 .await?
2308 {
2309 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2310 None => Ok(None),
2311 }
2312 }
2313
2314 pub async fn list_compute_workloads(
2316 &self,
2317 project: ProjectRef<'_>,
2318 ) -> Result<Vec<crate::compute::ComputeWorkload>, DeployError> {
2319 let prefix = crate::compute::workloads_prefix(project.as_str());
2320 let mut out = Vec::new();
2321 for key in self.kv.list_prefix(&prefix).await? {
2322 if let Some(bytes) = self.kv.get(&key).await? {
2323 if let Ok(w) = serde_json::from_slice::<crate::compute::ComputeWorkload>(&bytes) {
2324 out.push(w);
2325 }
2326 }
2327 }
2328 Ok(out)
2329 }
2330
2331 pub async fn list_compute_workloads_all(
2334 &self,
2335 ) -> Result<Vec<(String, crate::compute::ComputeWorkload)>, DeployError> {
2336 let mut out = Vec::new();
2337 for project in self.discover_projects().await? {
2338 for w in self
2339 .list_compute_workloads(ProjectRef::new(&project))
2340 .await?
2341 {
2342 out.push((project.clone(), w));
2343 }
2344 }
2345 Ok(out)
2346 }
2347
2348 pub async fn delete_compute_workload(
2351 &self,
2352 project: ProjectRef<'_>,
2353 name: &str,
2354 ) -> Result<bool, DeployError> {
2355 let key = crate::compute::workload_key(project.as_str(), name);
2356 let existed = self.kv.get(&key).await?.is_some();
2357 if existed {
2358 self.kv.delete(&key).await?;
2359 }
2360 Ok(existed)
2361 }
2362
2363 pub async fn set_replica_state(
2367 &self,
2368 project: ProjectRef<'_>,
2369 state: &crate::compute::ObservedInstance,
2370 ) -> Result<(), DeployError> {
2371 self.kv
2372 .put(
2373 &crate::compute::replica_state_key(
2374 project.as_str(),
2375 &state.handle.workload,
2376 state.handle.replica,
2377 ),
2378 serde_json::to_vec(state)?,
2379 )
2380 .await?;
2381 Ok(())
2382 }
2383
2384 pub async fn list_replica_states(
2389 &self,
2390 project: ProjectRef<'_>,
2391 workload: &str,
2392 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2393 let mut out = Vec::new();
2394 for key in self
2395 .kv
2396 .list_prefix(&crate::compute::replica_state_prefix(
2397 project.as_str(),
2398 workload,
2399 ))
2400 .await?
2401 {
2402 if let Some(bytes) = self.kv.get(&key).await? {
2403 if let Ok(mut state) =
2404 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2405 {
2406 backfill_replica_project(&mut state, project.as_str());
2407 out.push(state);
2408 }
2409 }
2410 }
2411 Ok(out)
2412 }
2413
2414 pub async fn list_all_replica_states(
2418 &self,
2419 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2420 let mut out = Vec::new();
2421 for project in self.discover_projects().await? {
2422 let prefix = crate::compute::replica_states_project_prefix(&project);
2423 for key in self.kv.list_prefix(&prefix).await? {
2424 if let Some(bytes) = self.kv.get(&key).await? {
2425 if let Ok(mut state) =
2426 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2427 {
2428 backfill_replica_project(&mut state, &project);
2432 out.push(state);
2433 }
2434 }
2435 }
2436 }
2437 Ok(out)
2438 }
2439
2440 pub async fn delete_replica_state(
2442 &self,
2443 project: ProjectRef<'_>,
2444 workload: &str,
2445 replica: u32,
2446 ) -> Result<(), DeployError> {
2447 self.kv
2448 .delete(&crate::compute::replica_state_key(
2449 project.as_str(),
2450 workload,
2451 replica,
2452 ))
2453 .await?;
2454 Ok(())
2455 }
2456
2457 pub async fn put_project(&self, p: &crate::project::Project) -> Result<String, DeployError> {
2469 let hash = p.id();
2470 let body = serde_json::to_vec(p).map_err(|e| DeployError::Serde(e.to_string()))?;
2471 let pointer = crate::project::pointer_key(&p.name);
2472 let mut history: Vec<String> =
2475 match self.kv.get(&crate::project::history_key(&p.name)).await? {
2476 Some(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
2477 None => Vec::new(),
2478 };
2479 if let Some(current) = self.kv.get(&pointer).await? {
2480 let current = String::from_utf8_lossy(¤t).into_owned();
2481 if current != hash {
2482 history.retain(|h| h != ¤t);
2483 history.insert(0, current);
2484 history.truncate(MAX_HISTORY);
2485 }
2486 }
2487 self.kv
2488 .write_batch(vec![
2489 WriteOp::Put(crate::project::spec_key(&hash), body),
2490 WriteOp::Put(pointer, hash.clone().into_bytes()),
2491 WriteOp::Put(
2492 crate::project::history_key(&p.name),
2493 serde_json::to_vec(&history).map_err(|e| DeployError::Serde(e.to_string()))?,
2494 ),
2495 ])
2496 .await?;
2497 Ok(hash)
2498 }
2499
2500 pub fn default_project_record() -> crate::project::Project {
2505 crate::project::Project {
2506 version: crate::SCHEMA_VERSION,
2507 name: crate::project::DEFAULT_PROJECT.to_string(),
2508 created_at: now_unix(),
2509 meta: crate::project::ProjectMeta::default(),
2510 config: crate::project::ProjectConfig::default(),
2511 secrets_ref: None,
2512 }
2513 }
2514
2515 pub async fn ensure_default_project(&self) -> Result<bool, DeployError> {
2523 let pointer = crate::project::pointer_key(crate::project::DEFAULT_PROJECT);
2524 if self.kv.get(&pointer).await?.is_some() {
2525 return Ok(false);
2526 }
2527 let default = Self::default_project_record();
2528 let hash = default.id();
2529 let body = serde_json::to_vec(&default).map_err(|e| DeployError::Serde(e.to_string()))?;
2530 self.kv
2531 .write_batch(vec![
2532 WriteOp::Put(crate::project::spec_key(&hash), body),
2533 WriteOp::Put(pointer, hash.into_bytes()),
2534 ])
2535 .await?;
2536 Ok(true)
2537 }
2538
2539 pub async fn project_exists(&self, name: &str) -> Result<bool, DeployError> {
2544 if name == crate::project::DEFAULT_PROJECT {
2545 return Ok(true);
2546 }
2547 Ok(self
2548 .kv
2549 .get(&crate::project::pointer_key(name))
2550 .await?
2551 .is_some())
2552 }
2553
2554 pub async fn get_project(
2556 &self,
2557 name: &str,
2558 ) -> Result<Option<crate::project::Project>, DeployError> {
2559 let Some(hash) = self.kv.get(&crate::project::pointer_key(name)).await? else {
2560 if name == crate::project::DEFAULT_PROJECT {
2564 return Ok(Some(Self::default_project_record()));
2565 }
2566 return Ok(None);
2567 };
2568 let hash = String::from_utf8_lossy(&hash).into_owned();
2569 match self.kv.get(&crate::project::spec_key(&hash)).await? {
2570 Some(bytes) => Ok(Some(
2571 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
2572 )),
2573 None if name == crate::project::DEFAULT_PROJECT => {
2576 Ok(Some(Self::default_project_record()))
2577 }
2578 None => Ok(None),
2579 }
2580 }
2581
2582 pub async fn list_projects(&self) -> Result<Vec<crate::project::Project>, DeployError> {
2586 let mut out = Vec::new();
2587 for key in self.kv.list_prefix(crate::project::POINTER_PREFIX).await? {
2588 let Some(name) = key.strip_prefix(crate::project::POINTER_PREFIX) else {
2589 continue;
2590 };
2591 if !name.is_empty() {
2592 if let Some(p) = self.get_project(name).await? {
2593 out.push(p);
2594 }
2595 }
2596 }
2597 if !out
2600 .iter()
2601 .any(|p| p.name == crate::project::DEFAULT_PROJECT)
2602 {
2603 out.push(Self::default_project_record());
2604 }
2605 out.sort_by(|a, b| a.name.cmp(&b.name));
2606 Ok(out)
2607 }
2608
2609 pub async fn delete_project(&self, name: &str) -> Result<bool, DeployError> {
2615 if name == crate::project::DEFAULT_PROJECT {
2616 return Err(DeployError::Conflict(
2617 "the `default` project cannot be deleted".to_string(),
2618 ));
2619 }
2620 let existed = self
2621 .kv
2622 .get(&crate::project::pointer_key(name))
2623 .await?
2624 .is_some();
2625 let prefix = crate::project::resource_prefix(name);
2631 let remaining = self.kv.list_prefix(&prefix).await?;
2632 if !remaining.is_empty() {
2633 return Err(DeployError::Conflict(format!(
2634 "project `{name}` still owns resources — delete these first, or \
2635 `project rm --force` to cascade: {}",
2636 Self::summarize_owned_resources(&prefix, &remaining)
2637 )));
2638 }
2639 self.kv
2640 .write_batch(vec![
2641 WriteOp::Delete(crate::project::pointer_key(name)),
2642 WriteOp::Delete(crate::project::history_key(name)),
2643 ])
2644 .await?;
2645 Ok(existed)
2646 }
2647
2648 fn summarize_owned_resources(prefix: &str, keys: &[String]) -> String {
2654 use std::collections::{BTreeMap, BTreeSet};
2655 let mut fam: BTreeMap<&str, (BTreeSet<&str>, usize)> = BTreeMap::new();
2657 for key in keys {
2658 let rest = key
2659 .strip_prefix(prefix)
2660 .unwrap_or(key)
2661 .trim_start_matches('/');
2662 let mut segs = rest.split('/');
2663 let Some(family) = segs.next().filter(|f| !f.is_empty()) else {
2664 continue;
2665 };
2666 let entry = fam.entry(family).or_default();
2667 entry.1 += 1;
2668 if let Some(name) = segs.next().filter(|n| !n.is_empty()) {
2669 entry.0.insert(name);
2670 }
2671 }
2672 fam.iter()
2673 .map(|(family, (names, count))| {
2674 if names.is_empty() {
2675 format!(
2676 "{family} ({count} key{})",
2677 if *count == 1 { "" } else { "s" }
2678 )
2679 } else {
2680 let shown: Vec<&str> = names.iter().take(10).copied().collect();
2681 let more = names.len() - shown.len();
2682 let suffix = if more > 0 {
2683 format!(", +{more} more")
2684 } else {
2685 String::new()
2686 };
2687 format!("{family}: [{}{suffix}]", shown.join(", "))
2688 }
2689 })
2690 .collect::<Vec<_>>()
2691 .join("; ")
2692 }
2693
2694 pub async fn enumerate_project_resources(
2709 &self,
2710 project: &str,
2711 ) -> Result<ProjectTeardownPlan, DeployError> {
2712 let pref = ProjectRef::new(project);
2713
2714 let mut site_set: std::collections::BTreeSet<String> =
2719 self.list_sites(pref).await?.into_iter().collect();
2720 let site_pref = keys::site_prefix(pref);
2721 for key in self.kv.list_prefix(&site_pref).await? {
2722 if let Some(site) = key.strip_prefix(&site_pref).filter(|s| !s.is_empty()) {
2723 site_set.insert(site.to_string());
2724 }
2725 }
2726 let sites: Vec<String> = site_set.into_iter().collect();
2727
2728 let functions: Vec<String> = self
2729 .list_stored_functions(pref)
2730 .await?
2731 .into_iter()
2732 .map(|f| f.name)
2733 .collect();
2734
2735 let mut compute = Vec::new();
2738 for w in self.list_compute_workloads(pref).await? {
2739 let volumes = match self.get_compute_spec(&w.active).await? {
2740 Some(spec) => spec.volumes.into_iter().map(|v| v.name).collect(),
2741 None => Vec::new(),
2742 };
2743 compute.push(ComputeTeardown {
2744 name: w.name,
2745 volumes,
2746 });
2747 }
2748
2749 let secret_prefix = keys::secret_prefix(pref);
2753 let mut secrets: Vec<String> = self
2754 .kv
2755 .list_prefix(&secret_prefix)
2756 .await?
2757 .into_iter()
2758 .filter_map(|k| k.strip_prefix(&secret_prefix).map(str::to_string))
2759 .filter(|n| !n.is_empty())
2760 .collect();
2761 secrets.sort();
2762
2763 let safelist = self
2767 .kv
2768 .list_prefix(&keys::graphql_safelist_prefix(pref))
2769 .await?
2770 .len();
2771
2772 let subgraph_prefix = keys::graphql_subgraph_prefix(pref);
2773 let mut subgraphs: Vec<String> = self
2774 .kv
2775 .list_prefix(&subgraph_prefix)
2776 .await?
2777 .into_iter()
2778 .filter_map(|k| k.strip_prefix(&subgraph_prefix).map(str::to_string))
2779 .filter(|n| !n.is_empty())
2780 .collect();
2781 subgraphs.sort();
2782
2783 let resource_prefix = crate::project::resource_prefix(project);
2786 let residual = self.kv.list_prefix(&resource_prefix).await?;
2787 let covered = ["site", "functions", "compute", "secret"];
2788 let mut other_families: std::collections::BTreeMap<String, usize> = Default::default();
2789 for key in &residual {
2790 let rest = key
2791 .strip_prefix(&resource_prefix)
2792 .unwrap_or(key)
2793 .trim_start_matches('/');
2794 let Some(family) = rest.split('/').next().filter(|f| !f.is_empty()) else {
2795 continue;
2796 };
2797 if covered.contains(&family) {
2798 continue;
2799 }
2800 *other_families.entry(family.to_string()).or_default() += 1;
2801 }
2802
2803 Ok(ProjectTeardownPlan {
2804 project: project.to_string(),
2805 sites,
2806 functions,
2807 compute,
2808 secrets,
2809 safelist,
2810 subgraphs,
2811 other_families,
2812 })
2813 }
2814
2815 pub async fn purge_project(&self, project: &str) -> Result<usize, DeployError> {
2833 if project == crate::project::DEFAULT_PROJECT {
2834 return Err(DeployError::Conflict(
2835 "the `default` project cannot be deleted".to_string(),
2836 ));
2837 }
2838 let _claim = self.domain_claim_lock.lock().await;
2842
2843 let pref = ProjectRef::new(project);
2844 let mut ops: Vec<WriteOp> = Vec::new();
2845
2846 for key in self
2848 .kv
2849 .list_prefix(&crate::project::resource_prefix(project))
2850 .await?
2851 {
2852 ops.push(WriteOp::Delete(key));
2853 }
2854 for key in self
2856 .kv
2857 .list_prefix(&keys::graphql_registry_prefix(pref))
2858 .await?
2859 {
2860 ops.push(WriteOp::Delete(key));
2861 }
2862 for key in self
2863 .kv
2864 .list_prefix(&keys::graphql_safelist_prefix(pref))
2865 .await?
2866 {
2867 ops.push(WriteOp::Delete(key));
2868 }
2869 ops.push(WriteOp::Delete(crate::project::pointer_key(project)));
2871 ops.push(WriteOp::Delete(crate::project::history_key(project)));
2872 for key in self.kv.list_prefix(crate::project::OWNER_PREFIX).await? {
2874 if let Some(bytes) = self.kv.get(&key).await? {
2875 if bytes == project.as_bytes() {
2876 ops.push(WriteOp::Delete(key));
2877 }
2878 }
2879 }
2880
2881 let purged = ops.len();
2882 if !ops.is_empty() {
2883 self.kv.write_batch(ops).await?;
2884 }
2885 self.bump_domain_epoch();
2887 Ok(purged)
2888 }
2889
2890 pub async fn activate(
2894 &self,
2895 project: ProjectRef<'_>,
2896 site: &str,
2897 id: &str,
2898 ) -> Result<(), DeployError> {
2899 let manifest = self
2900 .get_manifest(id)
2901 .await?
2902 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
2903 let missing = self.missing_blobs(&manifest).await?;
2904 if !missing.is_empty() {
2905 return Err(DeployError::Incomplete(missing));
2906 }
2907
2908 self.kv
2911 .put(&keys::current(project, site), id.as_bytes().to_vec())
2912 .await?;
2913
2914 let _ = self.record_history(project, site, id).await;
2918 Ok(())
2919 }
2920
2921 async fn record_history(
2924 &self,
2925 project: ProjectRef<'_>,
2926 site: &str,
2927 id: &str,
2928 ) -> Result<(), DeployError> {
2929 let mut history = self.history(project, site).await.unwrap_or_default();
2930 history.retain(|entry| entry.id != id);
2931 history.insert(
2932 0,
2933 HistoryEntry {
2934 id: id.to_string(),
2935 at: now_unix(),
2936 meta: None,
2937 },
2938 );
2939 history.truncate(MAX_HISTORY);
2940 self.kv
2941 .put(&keys::history(project, site), serde_json::to_vec(&history)?)
2942 .await?;
2943 Ok(())
2944 }
2945
2946 pub async fn history(
2948 &self,
2949 project: ProjectRef<'_>,
2950 site: &str,
2951 ) -> Result<Vec<HistoryEntry>, DeployError> {
2952 match self.kv.get(&keys::history(project, site)).await? {
2953 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2954 None => Ok(Vec::new()),
2955 }
2956 }
2957
2958 pub async fn deployments(
2961 &self,
2962 project: ProjectRef<'_>,
2963 site: &str,
2964 ) -> Result<DeploymentList, DeployError> {
2965 let mut deployments = self.history(project, site).await?;
2966 for entry in &mut deployments {
2967 entry.meta = self.get_meta(&entry.id).await?;
2968 }
2969 Ok(DeploymentList {
2970 current: self.current_id(project, site).await?,
2971 deployments,
2972 })
2973 }
2974
2975 async fn live_deployment_ids(&self, opts: &GcOptions) -> Result<BTreeSet<String>, DeployError> {
2986 let mut ids = BTreeSet::new();
2987 let now = now_unix();
2988 for project in self.discover_projects().await? {
2989 let pref = ProjectRef::new(&project);
2990 for key in self.kv.list_prefix(&keys::history_prefix(pref)).await? {
2991 if let Some(bytes) = self.kv.get(&key).await? {
2992 if let Ok(history) = serde_json::from_slice::<Vec<HistoryEntry>>(&bytes) {
2993 for (idx, entry) in history.iter().enumerate() {
2994 let within_count = opts.keep_last.is_none_or(|n| idx < n);
2995 let within_age = opts
2996 .keep_age_secs
2997 .is_some_and(|age| now.saturating_sub(entry.at) <= age);
2998 if within_count || within_age {
2999 ids.insert(entry.id.clone());
3000 }
3001 }
3002 }
3003 }
3004 }
3005 for prefix in [keys::current_prefix(pref), keys::alias_project_prefix(pref)] {
3007 for key in self.kv.list_prefix(&prefix).await? {
3008 if let Some(bytes) = self.kv.get(&key).await? {
3009 ids.insert(String::from_utf8_lossy(&bytes).into_owned());
3010 }
3011 }
3012 }
3013 }
3014 Ok(ids)
3015 }
3016
3017 async fn within_grace(
3022 &self,
3023 id: &str,
3024 now: u64,
3025 opts: &GcOptions,
3026 ) -> Result<bool, DeployError> {
3027 if opts.grace_secs == 0 {
3028 return Ok(false);
3029 }
3030 match self.get_meta(id).await? {
3031 Some(meta) => Ok(now.saturating_sub(meta.created_at) < opts.grace_secs),
3032 None => Ok(false),
3033 }
3034 }
3035
3036 pub async fn collect_garbage(&self, prune: bool) -> Result<GcReport, DeployError> {
3043 self.collect_garbage_with(prune, GcOptions::default()).await
3044 }
3045
3046 pub async fn collect_garbage_with(
3057 &self,
3058 prune: bool,
3059 opts: GcOptions,
3060 ) -> Result<GcReport, DeployError> {
3061 let live_ids = self.live_deployment_ids(&opts).await?;
3062 let now = now_unix();
3063
3064 let manifest_keys = self.kv.list_prefix("manifests/").await?;
3065 let manifests_total = manifest_keys.len();
3066 let mut referenced: BTreeSet<String> = BTreeSet::new();
3067 let mut orphan_manifests: Vec<String> = Vec::new();
3068 for key in &manifest_keys {
3069 let id = key.strip_prefix("manifests/").unwrap_or(key);
3070 let protected = live_ids.contains(id) || self.within_grace(id, now, &opts).await?;
3071 if protected {
3072 if let Some(bytes) = self.kv.get(key).await? {
3073 if let Ok(manifest) = Manifest::from_bytes(&bytes) {
3074 referenced.extend(manifest.blob_hashes());
3075 }
3076 }
3077 } else {
3078 orphan_manifests.push(key.clone());
3079 }
3080 }
3081
3082 let blobs = self.storage.list("").await?;
3083 let blobs_total = blobs.len();
3084 let mut blobs_removed = 0;
3085 let mut bytes_reclaimed = 0;
3086 for meta in &blobs {
3087 if !is_blob_key(&meta.key) {
3088 continue;
3089 }
3090 let hash = meta.key.rsplit('/').next().unwrap_or(&meta.key);
3091 if !referenced.contains(hash) {
3092 blobs_removed += 1;
3093 bytes_reclaimed += meta.size.unwrap_or(0);
3094 if prune {
3095 self.storage.delete(&meta.key).await?;
3096 }
3097 }
3098 }
3099
3100 if prune {
3106 let mut referenced_configs: BTreeSet<String> = BTreeSet::new();
3110 for project in self.discover_projects().await? {
3111 let site_prefix = keys::site_prefix(ProjectRef::new(&project));
3112 for pointer in self.kv.list_prefix(&site_prefix).await? {
3113 if let Some(bytes) = self.kv.get(&pointer).await? {
3114 referenced_configs.insert(String::from_utf8_lossy(&bytes).into_owned());
3115 }
3116 }
3117 }
3118 for key in self.kv.list_prefix("siteconfig/").await? {
3119 let hash = key.strip_prefix("siteconfig/").unwrap_or(&key);
3120 if !referenced_configs.contains(hash) {
3121 self.kv.delete(&key).await?;
3122 }
3123 }
3124 }
3125
3126 let manifests_removed = orphan_manifests.len();
3127 if prune {
3128 for key in &orphan_manifests {
3129 self.kv.delete(key).await?;
3130 if let Some(id) = key.strip_prefix("manifests/") {
3132 let _ = self.kv.delete(&keys::meta(id)).await;
3133 }
3134 }
3135 }
3136
3137 Ok(GcReport {
3138 manifests_total,
3139 manifests_removed,
3140 blobs_total,
3141 blobs_removed,
3142 bytes_reclaimed,
3143 })
3144 }
3145
3146 pub async fn scrub_blobs(&self) -> Result<ScrubReport, DeployError> {
3152 let blobs = self.storage.list("").await?;
3153 let mut report = ScrubReport::default();
3154 for meta in &blobs {
3155 if !is_blob_key(&meta.key) {
3156 continue;
3157 }
3158 report.checked += 1;
3159 let expected = meta
3160 .key
3161 .rsplit('/')
3162 .next()
3163 .unwrap_or(meta.key.as_str())
3164 .to_string();
3165 match self.hash_stored_object(&meta.key).await {
3166 Ok(actual) if actual == expected => {}
3167 Ok(actual) => report.mismatched.push(BlobMismatch {
3168 key: meta.key.clone(),
3169 expected,
3170 actual,
3171 }),
3172 Err(err) => report.errors.push(BlobReadError {
3173 key: meta.key.clone(),
3174 error: err.to_string(),
3175 }),
3176 }
3177 }
3178 Ok(report)
3179 }
3180
3181 pub fn invalidate_cache_keys(&self, keys: &[String]) {
3187 self.kv.invalidate_keys(keys);
3188 }
3189
3190 pub fn invalidate_cache(&self) {
3193 self.kv.invalidate_cache();
3194 }
3195
3196 pub async fn cert_status(&self) -> Result<Vec<crate::cert::CertStatus>, DeployError> {
3201 let mut out = Vec::new();
3202 for key in self.kv.list_prefix("cert/").await? {
3203 let domain = key.strip_prefix("cert/").unwrap_or(&key).to_string();
3204 if let Some(bytes) = self.kv.get(&key).await? {
3205 if let Ok(cert) = serde_json::from_slice::<crate::cert::StoredCert>(&bytes) {
3206 out.push(crate::cert::CertStatus {
3207 domain,
3208 not_after_unix: cert.not_after_unix,
3209 });
3210 }
3211 }
3212 }
3213 out.sort_by(|a, b| a.domain.cmp(&b.domain));
3214 Ok(out)
3215 }
3216
3217 async fn hash_stored_object(&self, key: &str) -> Result<String, DeployError> {
3219 let mut body = self.storage.get(key).await?.body;
3220 let mut hasher = Sha256::new();
3221 while let Some(chunk) = body.next().await {
3222 hasher.update(&chunk?);
3223 }
3224 Ok(hex::encode(hasher.finalize()))
3225 }
3226
3227 pub async fn list_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
3231 let prefix = keys::current_prefix(project);
3232 let keys = self.kv.list_prefix(&prefix).await?;
3233 Ok(keys
3234 .into_iter()
3235 .filter_map(|k| k.strip_prefix(&prefix).map(str::to_string))
3236 .collect())
3237 }
3238
3239 pub async fn list_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
3242 let mut out = Vec::new();
3243 for project in self.discover_projects().await? {
3244 for site in self.list_sites(ProjectRef::new(&project)).await? {
3245 out.push((project.clone(), site));
3246 }
3247 }
3248 Ok(out)
3249 }
3250
3251 pub async fn delete_site(
3258 &self,
3259 project: ProjectRef<'_>,
3260 site: &str,
3261 ) -> Result<(), DeployError> {
3262 use crate::kv::WriteOp;
3263 let _claim = self.domain_claim_lock.lock().await;
3266 let mut batch = vec![
3267 WriteOp::Delete(keys::site_pointer(project, site)),
3268 WriteOp::Delete(keys::current(project, site)),
3269 WriteOp::Delete(keys::history(project, site)),
3270 ];
3271 if let Some(config) = self.get_site_config(project, site).await? {
3272 for host in config.domains.exact_hosts() {
3273 batch.push(WriteOp::Delete(keys::domain(host)));
3274 }
3275 for wildcard in &config.domains.wildcards {
3276 if let Some(suffix) = wildcard.strip_prefix("*.") {
3277 batch.push(WriteOp::Delete(keys::wildcard(suffix)));
3278 }
3279 }
3280 }
3281 for key in self
3282 .kv
3283 .list_prefix(&keys::alias_prefix(project, site))
3284 .await?
3285 {
3286 batch.push(WriteOp::Delete(key));
3287 }
3288 for key in self
3289 .kv
3290 .list_prefix(&keys::domain_verification_prefix(project, site))
3291 .await?
3292 {
3293 batch.push(WriteOp::Delete(key));
3294 }
3295 self.kv.write_batch(batch).await?;
3296 self.bump_domain_epoch();
3298 Ok(())
3299 }
3300
3301 pub async fn current_id(
3303 &self,
3304 project: ProjectRef<'_>,
3305 site: &str,
3306 ) -> Result<Option<String>, DeployError> {
3307 match self.kv.get(&keys::current(project, site)).await? {
3308 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
3309 None => Ok(None),
3310 }
3311 }
3312
3313 pub async fn current_manifest(
3315 &self,
3316 project: ProjectRef<'_>,
3317 site: &str,
3318 ) -> Result<Option<Manifest>, DeployError> {
3319 match self.current_id(project, site).await? {
3320 Some(id) => self.get_manifest(&id).await,
3321 None => Ok(None),
3322 }
3323 }
3324
3325 pub async fn resolve(
3330 &self,
3331 project: ProjectRef<'_>,
3332 site: &str,
3333 path: &str,
3334 ) -> Result<Option<FileEntry>, DeployError> {
3335 let Some(manifest) = self.current_manifest(project, site).await? else {
3336 return Ok(None);
3337 };
3338 Ok(lookup(&manifest, path))
3339 }
3340}
3341
3342fn lookup(manifest: &Manifest, path: &str) -> Option<FileEntry> {
3344 let trimmed = path.trim_start_matches('/');
3345 if let Some(entry) = manifest.files.get(trimmed) {
3346 return Some(entry.clone());
3347 }
3348 let index = if trimmed.is_empty() {
3349 "index.html".to_string()
3350 } else {
3351 format!("{}/index.html", trimmed.trim_end_matches('/'))
3352 };
3353 manifest.files.get(&index).cloned()
3354}
3355
3356#[cfg(test)]
3357mod tests {
3358 use super::*;
3359 use crate::config::DeployConfig;
3360 use crate::ObjectMeta;
3361
3362 fn entry(hash: &str) -> FileEntry {
3363 FileEntry {
3364 hash: hash.to_string(),
3365 size: 0,
3366 content_type: None,
3367 variants: BTreeMap::new(),
3368 }
3369 }
3370
3371 #[test]
3372 fn manifest_id_is_deterministic() {
3373 let mut a = Manifest::default();
3374 a.files.insert("index.html".into(), entry("aa"));
3375 a.files.insert("style.css".into(), entry("bb"));
3376
3377 let mut b = Manifest::default();
3378 b.files.insert("style.css".into(), entry("bb"));
3380 b.files.insert("index.html".into(), entry("aa"));
3381
3382 assert_eq!(a.id().unwrap(), b.id().unwrap());
3383 }
3384
3385 #[test]
3386 fn manifest_carries_schema_version_and_reads_legacy() {
3387 let manifest = Manifest::default();
3389 assert_eq!(manifest.version, crate::SCHEMA_VERSION);
3390 assert!(manifest.to_bytes().unwrap().starts_with(b"{\"version\":1"));
3391
3392 let legacy = br#"{"files":{},"config":{}}"#;
3394 assert_eq!(Manifest::from_bytes(legacy).unwrap().version, 1);
3395 }
3396
3397 #[test]
3398 fn directory_index_fallback() {
3399 let mut m = Manifest::default();
3400 m.files.insert("index.html".into(), entry("root"));
3401 m.files.insert("blog/index.html".into(), entry("blog"));
3402
3403 assert_eq!(lookup(&m, "").unwrap().hash, "root");
3404 assert_eq!(lookup(&m, "/").unwrap().hash, "root");
3405 assert_eq!(lookup(&m, "blog").unwrap().hash, "blog");
3406 assert_eq!(lookup(&m, "blog/").unwrap().hash, "blog");
3407 assert!(lookup(&m, "missing.html").is_none());
3408 }
3409
3410 struct NullStorage;
3413
3414 #[async_trait::async_trait]
3415 impl Storage for NullStorage {
3416 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
3417 Err(StorageError::NotFound(String::new()))
3418 }
3419 async fn get_range(
3420 &self,
3421 _: &str,
3422 _: u64,
3423 _: Option<u64>,
3424 ) -> Result<GetObject, StorageError> {
3425 Err(StorageError::NotFound(String::new()))
3426 }
3427 async fn put(
3428 &self,
3429 _: &str,
3430 _: ByteStream,
3431 _: PutMeta,
3432 ) -> Result<ObjectMeta, StorageError> {
3433 Err(StorageError::unsupported("null"))
3434 }
3435 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
3436 Err(StorageError::NotFound(String::new()))
3437 }
3438 async fn delete(&self, _: &str) -> Result<(), StorageError> {
3439 Ok(())
3440 }
3441 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
3442 Ok(Vec::new())
3443 }
3444 }
3445
3446 #[derive(Default)]
3448 struct MemStorage {
3449 objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
3450 }
3451
3452 #[async_trait::async_trait]
3453 impl Storage for MemStorage {
3454 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
3455 let bytes = self
3456 .objects
3457 .lock()
3458 .unwrap()
3459 .get(key)
3460 .cloned()
3461 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
3462 let size = bytes.len() as u64;
3463 let body: ByteStream =
3464 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
3465 Ok(GetObject {
3466 meta: ObjectMeta {
3467 key: key.to_string(),
3468 size: Some(size),
3469 ..Default::default()
3470 },
3471 body,
3472 })
3473 }
3474 async fn get_range(
3475 &self,
3476 key: &str,
3477 _: u64,
3478 _: Option<u64>,
3479 ) -> Result<GetObject, StorageError> {
3480 self.get(key).await
3481 }
3482 async fn put(
3483 &self,
3484 key: &str,
3485 mut body: ByteStream,
3486 _: PutMeta,
3487 ) -> Result<ObjectMeta, StorageError> {
3488 let mut buf = Vec::new();
3489 while let Some(chunk) = body.next().await {
3490 buf.extend_from_slice(&chunk?);
3491 }
3492 let size = buf.len() as u64;
3493 self.objects.lock().unwrap().insert(key.to_string(), buf);
3494 Ok(ObjectMeta {
3495 key: key.to_string(),
3496 size: Some(size),
3497 ..Default::default()
3498 })
3499 }
3500 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
3501 let map = self.objects.lock().unwrap();
3502 let bytes = map
3503 .get(key)
3504 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
3505 Ok(ObjectMeta {
3506 key: key.to_string(),
3507 size: Some(bytes.len() as u64),
3508 ..Default::default()
3509 })
3510 }
3511 async fn delete(&self, key: &str) -> Result<(), StorageError> {
3512 self.objects.lock().unwrap().remove(key);
3513 Ok(())
3514 }
3515 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, StorageError> {
3516 Ok(self
3517 .objects
3518 .lock()
3519 .unwrap()
3520 .keys()
3521 .filter(|k| k.starts_with(prefix))
3522 .map(|k| ObjectMeta {
3523 key: k.clone(),
3524 ..Default::default()
3525 })
3526 .collect())
3527 }
3528 }
3529
3530 fn once_bytes(b: &'static [u8]) -> ByteStream {
3532 futures::stream::once(async move { Ok(bytes::Bytes::from_static(b)) }).boxed()
3533 }
3534
3535 fn manifest_with(files: &[(&str, &str)]) -> Manifest {
3537 let mut m = Manifest::default();
3538 for (path, hash) in files {
3539 m.files.insert(
3540 (*path).to_string(),
3541 FileEntry {
3542 hash: (*hash).to_string(),
3543 size: 1,
3544 content_type: None,
3545 variants: Default::default(),
3546 },
3547 );
3548 }
3549 m
3550 }
3551
3552 #[tokio::test]
3553 async fn function_storage_versioning_alias_rollback() {
3554 use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
3555 use crate::kv::MemoryKv;
3556
3557 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3558 assert!(store
3559 .list_stored_functions(ProjectRef::DEFAULT)
3560 .await
3561 .unwrap()
3562 .is_empty());
3563
3564 let mut f = Function::new(
3565 "resize",
3566 Owner::Project("acme".into()),
3567 "hashA",
3568 FunctionConfig::default(),
3569 Lifecycle::Independent,
3570 1,
3571 );
3572 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3573 assert_eq!(
3574 store
3575 .get_function(ProjectRef::DEFAULT, "resize")
3576 .await
3577 .unwrap()
3578 .unwrap()
3579 .active,
3580 "hashA"
3581 );
3582 assert_eq!(
3583 store
3584 .list_stored_functions(ProjectRef::DEFAULT)
3585 .await
3586 .unwrap()
3587 .len(),
3588 1
3589 );
3590
3591 f.upsert_version("hashB", Lifecycle::Independent, 2);
3593 f.set_alias("prod", "hashA").unwrap();
3594 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3595 let got = store
3596 .get_function(ProjectRef::DEFAULT, "resize")
3597 .await
3598 .unwrap()
3599 .unwrap();
3600 assert_eq!(got.active, "hashB");
3601 assert_eq!(got.aliases.get("prod").map(String::as_str), Some("hashA"));
3602
3603 assert!(store
3605 .delete_function(ProjectRef::DEFAULT, "resize")
3606 .await
3607 .unwrap());
3608 assert!(store
3609 .get_function(ProjectRef::DEFAULT, "resize")
3610 .await
3611 .unwrap()
3612 .is_none());
3613 assert!(!store
3614 .delete_function(ProjectRef::DEFAULT, "resize")
3615 .await
3616 .unwrap());
3617 }
3618
3619 #[tokio::test]
3620 async fn delete_function_sweeps_the_whole_subtree_only_for_that_function() {
3621 use crate::function::keys as fk;
3622 use crate::kv::{KvStore, MemoryKv};
3623
3624 let kv = Arc::new(MemoryKv::new());
3625 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3626 let proj = ProjectRef::new("acme");
3627 for k in [
3630 fk::meta("acme", "greeter"),
3631 fk::version("acme", "greeter", "v1"),
3632 fk::alias("acme", "greeter", "prod"),
3633 fk::trigger("acme", "greeter", "t1"),
3634 fk::invocation("acme", "greeter", "i1"),
3635 fk::metering("acme", "greeter"),
3636 ] {
3637 kv.put(&k, vec![1]).await.unwrap();
3638 }
3639 kv.put(&fk::version("acme", "greeterx", "z1"), vec![1])
3641 .await
3642 .unwrap();
3643
3644 assert!(store.delete_function(proj, "greeter").await.unwrap());
3645
3646 assert!(kv
3648 .get(&fk::meta("acme", "greeter"))
3649 .await
3650 .unwrap()
3651 .is_none());
3652 assert!(kv
3653 .list_prefix(&format!("{}/", fk::meta("acme", "greeter")))
3654 .await
3655 .unwrap()
3656 .is_empty());
3657 assert!(kv
3658 .get(&fk::metering("acme", "greeter"))
3659 .await
3660 .unwrap()
3661 .is_none());
3662 assert!(kv
3664 .get(&fk::version("acme", "greeterx", "z1"))
3665 .await
3666 .unwrap()
3667 .is_some());
3668 }
3669
3670 #[tokio::test]
3671 async fn delete_project_refusal_names_what_remains() {
3672 use crate::function::keys as fk;
3673 use crate::kv::{KvStore, MemoryKv};
3674
3675 let kv = Arc::new(MemoryKv::new());
3676 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3677 kv.put(&fk::meta("acme", "greeter"), vec![1]).await.unwrap();
3679 kv.put("project/acme/graphql/safelist/op1", vec![1])
3680 .await
3681 .unwrap();
3682 kv.put("project/acme/graphql/safelist/op2", vec![1])
3683 .await
3684 .unwrap();
3685
3686 let msg = store.delete_project("acme").await.unwrap_err().to_string();
3687 assert!(msg.contains("functions: [greeter]"), "{msg}");
3689 assert!(msg.contains("graphql"), "{msg}");
3690 assert!(msg.contains("--force"), "{msg}");
3691 }
3692
3693 #[tokio::test]
3694 async fn function_invocation_and_idempotency_storage() {
3695 use crate::function::{
3696 Function, FunctionConfig, Invocation, InvocationResult, InvocationStatus, InvokeMode,
3697 Lifecycle, Owner,
3698 };
3699 use crate::kv::MemoryKv;
3700
3701 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3702 let f = Function::new(
3704 "greeter",
3705 Owner::Project("acme".into()),
3706 "hashA",
3707 FunctionConfig::default(),
3708 Lifecycle::Independent,
3709 1,
3710 );
3711 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3712
3713 let mut inv = Invocation {
3714 id: "inv-1".into(),
3715 function: "greeter".into(),
3716 version: "hashA".into(),
3717 mode: InvokeMode::Async,
3718 status: InvocationStatus::Queued,
3719 idempotency_key: Some("key-1".into()),
3720 attempts: 0,
3721 lease_expires: None,
3722 request_b64: None,
3723 request_content_type: None,
3724 result: None,
3725 created: 10,
3726 updated: 10,
3727 };
3728 store
3729 .put_invocation(ProjectRef::DEFAULT, &inv)
3730 .await
3731 .unwrap();
3732 store
3733 .put_idempotency(ProjectRef::DEFAULT, "greeter", "key-1", "inv-1")
3734 .await
3735 .unwrap();
3736
3737 assert_eq!(
3739 store
3740 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3741 .await
3742 .unwrap()
3743 .unwrap()
3744 .status,
3745 InvocationStatus::Queued
3746 );
3747 assert_eq!(
3748 store
3749 .get_idempotency(ProjectRef::DEFAULT, "greeter", "key-1")
3750 .await
3751 .unwrap(),
3752 Some("inv-1".to_string())
3753 );
3754 assert_eq!(
3755 store
3756 .list_invocations(ProjectRef::DEFAULT, "greeter")
3757 .await
3758 .unwrap()
3759 .len(),
3760 1
3761 );
3762 assert_eq!(
3764 store
3765 .list_stored_functions(ProjectRef::DEFAULT)
3766 .await
3767 .unwrap()
3768 .len(),
3769 1
3770 );
3771
3772 inv.status = InvocationStatus::Succeeded;
3774 inv.attempts = 1;
3775 inv.result = Some(InvocationResult {
3776 status: 200,
3777 content_type: Some("text/plain".into()),
3778 body_b64: "aGVsbG8=".into(),
3779 });
3780 inv.updated = 20;
3781 store
3782 .put_invocation(ProjectRef::DEFAULT, &inv)
3783 .await
3784 .unwrap();
3785 let got = store
3786 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3787 .await
3788 .unwrap()
3789 .unwrap();
3790 assert!(got.is_terminal());
3791 assert_eq!(got.result.unwrap().status, 200);
3792
3793 assert!(store
3795 .get_idempotency(ProjectRef::DEFAULT, "greeter", "absent")
3796 .await
3797 .unwrap()
3798 .is_none());
3799 }
3800
3801 #[tokio::test]
3802 async fn notification_ledger_provisions_and_retracts_through_the_store() {
3803 use crate::blob_notify::{ManagedResource, ProvisionTier};
3804 use crate::blob_provision::{ensure_watch, retract_watch, ProvisionError, WatchProvider};
3805 use crate::kv::MemoryKv;
3806
3807 struct StoreMock;
3809 #[async_trait::async_trait]
3810 impl WatchProvider for StoreMock {
3811 fn name(&self) -> &str {
3812 "mock"
3813 }
3814 fn recipe(&self, _prefix: &str) -> String {
3815 String::new()
3816 }
3817 async fn provision(
3818 &self,
3819 _prefix: &str,
3820 ) -> Result<Vec<ManagedResource>, ProvisionError> {
3821 Ok(vec![ManagedResource::new("queue", "q-1")])
3822 }
3823 async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
3824 Ok(true)
3825 }
3826 async fn retract(&self, _res: &[ManagedResource]) -> Result<(), ProvisionError> {
3827 Ok(())
3828 }
3829 }
3830
3831 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3832 assert!(store
3833 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3834 .await
3835 .unwrap()
3836 .is_none());
3837
3838 let provider = StoreMock;
3840 let out = ensure_watch(
3841 &provider,
3842 ProvisionTier::Provision,
3843 "ingest",
3844 "uploads/",
3845 &store,
3846 7,
3847 )
3848 .await
3849 .unwrap();
3850 assert!(matches!(
3851 out,
3852 crate::blob_provision::ProvisionOutcome::Ready
3853 ));
3854 let record = store
3855 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3856 .await
3857 .unwrap()
3858 .expect("the pipeline is recorded in the store ledger");
3859 assert_eq!(record.provider, "mock");
3860 assert_eq!(
3861 store
3862 .list_managed_notifications(ProjectRef::DEFAULT, "ingest")
3863 .await
3864 .unwrap()
3865 .len(),
3866 1
3867 );
3868
3869 retract_watch(&provider, &record, &store).await.unwrap();
3871 assert!(store
3872 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3873 .await
3874 .unwrap()
3875 .is_none());
3876 }
3877
3878 #[tokio::test]
3879 async fn workflow_definition_and_run_storage() {
3880 use crate::kv::MemoryKv;
3881 use crate::workflow::{Step, Workflow, WorkflowRun};
3882
3883 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3884 assert!(store
3885 .get_workflow(ProjectRef::DEFAULT, "etl")
3886 .await
3887 .unwrap()
3888 .is_none());
3889 assert!(store
3890 .list_workflows(ProjectRef::DEFAULT)
3891 .await
3892 .unwrap()
3893 .is_empty());
3894
3895 let wf = Workflow {
3896 name: "etl".into(),
3897 steps: vec![
3898 Step {
3899 id: "a".into(),
3900 function: "extract".into(),
3901 depends_on: vec![],
3902 retry: Default::default(),
3903 compensate: None,
3904 },
3905 Step {
3906 id: "b".into(),
3907 function: "load".into(),
3908 depends_on: vec!["a".into()],
3909 retry: Default::default(),
3910 compensate: None,
3911 },
3912 ],
3913 };
3914 store.put_workflow(ProjectRef::DEFAULT, &wf).await.unwrap();
3915 assert_eq!(
3916 store
3917 .get_workflow(ProjectRef::DEFAULT, "etl")
3918 .await
3919 .unwrap()
3920 .unwrap(),
3921 wf
3922 );
3923 assert_eq!(
3924 store
3925 .list_workflows(ProjectRef::DEFAULT)
3926 .await
3927 .unwrap()
3928 .len(),
3929 1
3930 );
3931
3932 let run = WorkflowRun::start(&wf, "r1", None, 5);
3934 store
3935 .put_workflow_run(ProjectRef::DEFAULT, &run)
3936 .await
3937 .unwrap();
3938 assert_eq!(
3939 store
3940 .get_workflow_run(ProjectRef::DEFAULT, "etl", "r1")
3941 .await
3942 .unwrap()
3943 .unwrap(),
3944 run
3945 );
3946 assert_eq!(
3947 store
3948 .list_workflow_runs(ProjectRef::DEFAULT, "etl")
3949 .await
3950 .unwrap()
3951 .len(),
3952 1
3953 );
3954 assert_eq!(
3956 store
3957 .list_workflows(ProjectRef::DEFAULT)
3958 .await
3959 .unwrap()
3960 .len(),
3961 1
3962 );
3963
3964 assert!(store
3966 .delete_workflow(ProjectRef::DEFAULT, "etl")
3967 .await
3968 .unwrap());
3969 assert!(store
3970 .get_workflow(ProjectRef::DEFAULT, "etl")
3971 .await
3972 .unwrap()
3973 .is_none());
3974 assert!(!store
3975 .delete_workflow(ProjectRef::DEFAULT, "etl")
3976 .await
3977 .unwrap());
3978 }
3979
3980 #[tokio::test]
3981 async fn function_trigger_storage_round_trips() {
3982 use crate::function::{
3983 Function, FunctionConfig, FunctionTrigger, Lifecycle, Owner, TriggerKind,
3984 };
3985 use crate::kv::MemoryKv;
3986
3987 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3988 let f = Function::new(
3989 "worker",
3990 Owner::Project("acme".into()),
3991 "hashA",
3992 FunctionConfig::default(),
3993 Lifecycle::Independent,
3994 1,
3995 );
3996 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3997 assert!(store
3998 .list_triggers(ProjectRef::DEFAULT, "worker")
3999 .await
4000 .unwrap()
4001 .is_empty());
4002
4003 let cron = FunctionTrigger {
4004 id: "tick".into(),
4005 kind: TriggerKind::Cron {
4006 schedule: "* * * * *".into(),
4007 overlap: Default::default(),
4008 },
4009 last_fired_minute: None,
4010 };
4011 store
4012 .put_trigger(ProjectRef::DEFAULT, "worker", &cron)
4013 .await
4014 .unwrap();
4015 let queue = FunctionTrigger {
4016 id: "jobs".into(),
4017 kind: TriggerKind::Queue {
4018 topic: "jobs".into(),
4019 group: String::new(),
4020 start: Default::default(),
4021 },
4022 last_fired_minute: None,
4023 };
4024 store
4025 .put_trigger(ProjectRef::DEFAULT, "worker", &queue)
4026 .await
4027 .unwrap();
4028
4029 assert_eq!(
4030 store
4031 .list_triggers(ProjectRef::DEFAULT, "worker")
4032 .await
4033 .unwrap()
4034 .len(),
4035 2
4036 );
4037 assert_eq!(
4038 store
4039 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
4040 .await
4041 .unwrap()
4042 .unwrap(),
4043 cron
4044 );
4045 assert_eq!(
4047 store
4048 .list_stored_functions(ProjectRef::DEFAULT)
4049 .await
4050 .unwrap()
4051 .len(),
4052 1
4053 );
4054
4055 let mut fired = cron.clone();
4057 fired.last_fired_minute = Some(42);
4058 store
4059 .put_trigger(ProjectRef::DEFAULT, "worker", &fired)
4060 .await
4061 .unwrap();
4062 assert_eq!(
4063 store
4064 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
4065 .await
4066 .unwrap()
4067 .unwrap()
4068 .last_fired_minute,
4069 Some(42)
4070 );
4071
4072 assert!(store
4074 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
4075 .await
4076 .unwrap());
4077 assert!(!store
4078 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
4079 .await
4080 .unwrap());
4081 assert_eq!(
4082 store
4083 .list_triggers(ProjectRef::DEFAULT, "worker")
4084 .await
4085 .unwrap()
4086 .len(),
4087 1
4088 );
4089 }
4090
4091 #[tokio::test]
4092 async fn function_metering_storage_is_tenant_isolated() {
4093 use crate::function::{Metering, MeteringSample};
4094 use crate::kv::MemoryKv;
4095
4096 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4097 assert!(store
4098 .get_metering(ProjectRef::DEFAULT, "a")
4099 .await
4100 .unwrap()
4101 .is_none());
4102
4103 let mut ma = Metering::new("a");
4104 ma.record(
4105 &MeteringSample {
4106 success: true,
4107 duration_ms: 4,
4108 bytes_in: 1,
4109 bytes_out: 2,
4110 },
4111 10,
4112 );
4113 store.put_metering(ProjectRef::DEFAULT, &ma).await.unwrap();
4114
4115 let mut mb = Metering::new("b");
4116 mb.record(
4117 &MeteringSample {
4118 success: false,
4119 duration_ms: 9,
4120 bytes_in: 0,
4121 bytes_out: 0,
4122 },
4123 11,
4124 );
4125 store.put_metering(ProjectRef::DEFAULT, &mb).await.unwrap();
4126
4127 assert_eq!(
4129 store
4130 .get_metering(ProjectRef::DEFAULT, "a")
4131 .await
4132 .unwrap()
4133 .unwrap()
4134 .successes,
4135 1
4136 );
4137 assert_eq!(
4138 store
4139 .get_metering(ProjectRef::DEFAULT, "b")
4140 .await
4141 .unwrap()
4142 .unwrap()
4143 .failures,
4144 1
4145 );
4146 let all = store.list_metering(ProjectRef::DEFAULT).await.unwrap();
4147 assert_eq!(all.len(), 2);
4148 }
4149
4150 #[tokio::test]
4151 async fn managed_dns_ledger_round_trip_and_retract() {
4152 use crate::dns_managed::{ManagedDns, ManagedRecord};
4153 use crate::kv::MemoryKv;
4154
4155 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4156 assert!(store
4157 .get_managed_dns(
4158 ProjectRef::DEFAULT,
4159 &SiteName::new("blog"),
4160 "www.example.com"
4161 )
4162 .await
4163 .unwrap()
4164 .is_none());
4165
4166 let ledger = ManagedDns::new(
4167 "www.example.com",
4168 "cloudflare",
4169 vec![ManagedRecord {
4170 kind: "A".into(),
4171 name: "www.example.com".into(),
4172 value: "203.0.113.7".into(),
4173 ttl: 300,
4174 }],
4175 10,
4176 );
4177 store
4178 .set_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"), &ledger)
4179 .await
4180 .unwrap();
4181 assert_eq!(
4183 store
4184 .get_managed_dns(
4185 ProjectRef::DEFAULT,
4186 &SiteName::new("blog"),
4187 "WWW.example.com."
4188 )
4189 .await
4190 .unwrap(),
4191 Some(ledger.clone())
4192 );
4193 assert_eq!(
4194 store
4195 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
4196 .await
4197 .unwrap(),
4198 vec![ledger]
4199 );
4200
4201 store
4202 .remove_managed_dns(
4203 ProjectRef::DEFAULT,
4204 &SiteName::new("blog"),
4205 "www.example.com",
4206 )
4207 .await
4208 .unwrap();
4209 assert!(store
4210 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
4211 .await
4212 .unwrap()
4213 .is_empty());
4214 }
4215
4216 #[tokio::test]
4217 async fn site_config_round_trip_and_host_routing() {
4218 use crate::config::{DomainConfig, SiteConfig};
4219 use crate::kv::MemoryKv;
4220
4221 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4222 let config = SiteConfig {
4223 domains: DomainConfig {
4224 primary: Some("example.com".into()),
4225 aliases: vec!["www.example.com".into()],
4226 wildcards: vec!["*.example.com".into()],
4227 ..Default::default()
4228 },
4229 ..Default::default()
4230 };
4231 store
4232 .set_site_config(ProjectRef::DEFAULT, "blog", &config)
4233 .await
4234 .unwrap();
4235
4236 let resolved = |host: &'static str| {
4237 let store = store.clone();
4238 async move {
4239 store
4240 .resolve_site_by_host(host)
4241 .await
4242 .unwrap()
4243 .map(|o| o.site)
4244 }
4245 };
4246 assert_eq!(resolved("example.com").await.as_deref(), Some("blog")); assert_eq!(resolved("www.example.com").await.as_deref(), Some("blog")); assert_eq!(resolved("api.example.com").await.as_deref(), Some("blog")); assert_eq!(resolved("a.b.example.com").await.as_deref(), Some("blog")); assert_eq!(resolved("other.com").await, None);
4251
4252 store
4254 .set_site_config(ProjectRef::DEFAULT, "blog", &SiteConfig::default())
4255 .await
4256 .unwrap();
4257 assert_eq!(resolved("example.com").await, None);
4258 }
4259
4260 #[tokio::test]
4265 async fn resolve_cache_invalidates_negative_entry_on_domain_add() {
4266 use crate::config::{DomainConfig, SiteConfig};
4267 use crate::kv::MemoryKv;
4268
4269 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4270 assert_eq!(
4272 store.resolve_site_by_host("shop.example").await.unwrap(),
4273 None
4274 );
4275 store
4277 .set_site_config(
4278 ProjectRef::DEFAULT,
4279 "shop",
4280 &SiteConfig {
4281 domains: DomainConfig {
4282 primary: Some("shop.example".into()),
4283 ..Default::default()
4284 },
4285 ..Default::default()
4286 },
4287 )
4288 .await
4289 .unwrap();
4290 assert_eq!(
4292 store
4293 .resolve_site_by_host("shop.example")
4294 .await
4295 .unwrap()
4296 .map(|o| o.site)
4297 .as_deref(),
4298 Some("shop")
4299 );
4300 store
4302 .delete_site(ProjectRef::DEFAULT, "shop")
4303 .await
4304 .unwrap();
4305 assert_eq!(
4306 store.resolve_site_by_host("shop.example").await.unwrap(),
4307 None
4308 );
4309 }
4310
4311 #[tokio::test]
4317 async fn wildcard_vhost_precedence_exact_beats_wildcard() {
4318 use crate::config::{DomainConfig, SiteConfig};
4319 use crate::kv::MemoryKv;
4320
4321 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4322 let attach = |site: &'static str, domains: DomainConfig| {
4323 let store = store.clone();
4324 async move {
4325 store
4326 .set_site_config(
4327 ProjectRef::DEFAULT,
4328 site,
4329 &SiteConfig {
4330 domains,
4331 ..Default::default()
4332 },
4333 )
4334 .await
4335 .unwrap();
4336 }
4337 };
4338 attach(
4340 "portal",
4341 DomainConfig {
4342 wildcards: vec!["*.construens.com".into()],
4343 ..Default::default()
4344 },
4345 )
4346 .await;
4347 attach(
4349 "console",
4350 DomainConfig {
4351 primary: Some("console.construens.com".into()),
4352 ..Default::default()
4353 },
4354 )
4355 .await;
4356 attach(
4358 "vip",
4359 DomainConfig {
4360 primary: Some("vip.construens.com".into()),
4361 ..Default::default()
4362 },
4363 )
4364 .await;
4365
4366 let resolved = |host: &'static str| {
4367 let store = store.clone();
4368 async move {
4369 store
4370 .resolve_site_by_host(host)
4371 .await
4372 .unwrap()
4373 .map(|o| o.site)
4374 }
4375 };
4376 assert_eq!(
4378 resolved("console.construens.com").await.as_deref(),
4379 Some("console")
4380 );
4381 assert_eq!(resolved("vip.construens.com").await.as_deref(), Some("vip"));
4382 assert_eq!(
4384 resolved("tenant7.construens.com").await.as_deref(),
4385 Some("portal")
4386 );
4387 assert_eq!(
4388 resolved("anything-else.construens.com").await.as_deref(),
4389 Some("portal")
4390 );
4391 assert_eq!(
4392 resolved("deep.team.construens.com").await.as_deref(),
4393 Some("portal")
4394 );
4395 assert_eq!(resolved("construens.com").await, None);
4397 assert_eq!(resolved("console.example.com").await, None);
4398 }
4399
4400 #[tokio::test]
4405 async fn wildcard_attaches_and_routes_without_real_dns_admin_override() {
4406 use crate::domain_verify::VerificationMethod;
4407 use crate::kv::MemoryKv;
4408
4409 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4410 let site = SiteName::new("portal");
4411 store
4414 .start_domain_verification(
4415 ProjectRef::DEFAULT,
4416 &site,
4417 "*.construens.com",
4418 VerificationMethod::Dns,
4419 0,
4420 )
4421 .await
4422 .unwrap();
4423 store
4424 .mark_domain_verified(ProjectRef::DEFAULT, &site, "*.construens.com")
4425 .await
4426 .unwrap();
4427 store
4428 .attach_verified_domain(ProjectRef::DEFAULT, &site, "*.construens.com")
4429 .await
4430 .unwrap();
4431
4432 assert_eq!(
4434 store
4435 .resolve_site_by_host("tenant7.construens.com")
4436 .await
4437 .unwrap()
4438 .map(|o| o.site)
4439 .as_deref(),
4440 Some("portal")
4441 );
4442 assert_eq!(
4444 store
4445 .resolve_site_by_host("construens.com")
4446 .await
4447 .unwrap()
4448 .map(|o| o.site),
4449 None
4450 );
4451 }
4452
4453 #[tokio::test]
4454 async fn site_config_is_content_addressed_and_dedups() {
4455 use crate::config::SiteConfig;
4456 use crate::kv::MemoryKv;
4457
4458 let kv = Arc::new(MemoryKv::new());
4459 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
4460
4461 let mut cfg = SiteConfig::default();
4465 cfg.security.https_redirect = true;
4466 store
4468 .set_site_config(ProjectRef::DEFAULT, "s1", &cfg)
4469 .await
4470 .unwrap();
4471 let mut cfg2 = cfg.clone();
4472 cfg2.security.https_redirect = false;
4473 store
4474 .set_site_config(ProjectRef::DEFAULT, "s2", &cfg2)
4475 .await
4476 .unwrap();
4477 store
4478 .set_site_config(ProjectRef::DEFAULT, "s3", &cfg)
4479 .await
4480 .unwrap(); let bodies = kv.list_prefix("siteconfig/").await.unwrap();
4484 assert_eq!(bodies.len(), 2, "s1/s3 share a body; s2 distinct");
4485 let pointers = kv.list_prefix("project/default/site/").await.unwrap();
4486 assert_eq!(pointers.len(), 3);
4487
4488 assert!(
4490 store
4491 .get_site_config(ProjectRef::DEFAULT, "s1")
4492 .await
4493 .unwrap()
4494 .unwrap()
4495 .security
4496 .https_redirect
4497 );
4498 assert_eq!(
4499 store
4500 .get_site_config(ProjectRef::DEFAULT, "missing")
4501 .await
4502 .unwrap(),
4503 None
4504 );
4505
4506 let mut edited = cfg.clone();
4509 edited.security.frame_options = Some("DENY".into());
4510 store
4511 .set_site_config(ProjectRef::DEFAULT, "s1", &edited)
4512 .await
4513 .unwrap();
4514 store.collect_garbage(true).await.unwrap();
4515 assert_eq!(kv.list_prefix("siteconfig/").await.unwrap().len(), 3);
4517 store
4519 .set_site_config(ProjectRef::DEFAULT, "s3", &edited)
4520 .await
4521 .unwrap();
4522 store.collect_garbage(true).await.unwrap();
4523 let remaining = kv.list_prefix("siteconfig/").await.unwrap();
4524 assert_eq!(remaining.len(), 2, "orphaned shared body reclaimed");
4525 assert!(
4527 store
4528 .get_site_config(ProjectRef::DEFAULT, "s1")
4529 .await
4530 .unwrap()
4531 .unwrap()
4532 .security
4533 .https_redirect
4534 );
4535 assert!(
4536 !store
4537 .get_site_config(ProjectRef::DEFAULT, "s2")
4538 .await
4539 .unwrap()
4540 .unwrap()
4541 .security
4542 .https_redirect
4543 );
4544 }
4545
4546 #[tokio::test]
4547 async fn cert_status_lists_domains_and_expiry_without_keys() {
4548 use crate::cert::StoredCert;
4549 use crate::kv::MemoryKv;
4550
4551 let kv = Arc::new(MemoryKv::new());
4552 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
4553 for (domain, not_after) in [("b.example.com", 2000u64), ("a.example.com", 1000u64)] {
4555 let cert = StoredCert::new("CHAINPEM", "KEYPEM", not_after);
4556 kv.put(
4557 &crate::cert::cert_key(domain),
4558 serde_json::to_vec(&cert).unwrap(),
4559 )
4560 .await
4561 .unwrap();
4562 }
4563 kv.put("site/x/config", b"{}".to_vec()).await.unwrap();
4564
4565 let status = store.cert_status().await.unwrap();
4566 assert_eq!(status.len(), 2);
4567 assert_eq!(status[0].domain, "a.example.com");
4569 assert_eq!(status[0].not_after_unix, 1000);
4570 assert_eq!(status[1].domain, "b.example.com");
4571 }
4572
4573 #[tokio::test]
4574 async fn domain_verification_gates_attachment() {
4575 use crate::domain_verify::VerificationMethod;
4576
4577 let store = store();
4578
4579 let v1 = store
4581 .start_domain_verification(
4582 ProjectRef::DEFAULT,
4583 &SiteName::new("blog"),
4584 "example.com",
4585 VerificationMethod::Dns,
4586 100,
4587 )
4588 .await
4589 .unwrap();
4590 let v2 = store
4591 .start_domain_verification(
4592 ProjectRef::DEFAULT,
4593 &SiteName::new("blog"),
4594 "example.com",
4595 VerificationMethod::Dns,
4596 200,
4597 )
4598 .await
4599 .unwrap();
4600 assert_eq!(v1.token, v2.token, "same method → same pending token");
4601 assert!(!store
4602 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4603 .await
4604 .unwrap());
4605
4606 assert!(store
4608 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4609 .await
4610 .is_err());
4611
4612 store
4614 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4615 .await
4616 .unwrap();
4617 assert!(store
4618 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4619 .await
4620 .unwrap());
4621 store
4622 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4623 .await
4624 .unwrap();
4625 assert_eq!(
4626 store
4627 .resolve_site_by_host("example.com")
4628 .await
4629 .unwrap()
4630 .map(|o| o.site)
4631 .as_deref(),
4632 Some("blog")
4633 );
4634 store
4636 .start_domain_verification(
4637 ProjectRef::DEFAULT,
4638 &SiteName::new("blog"),
4639 "www.example.com",
4640 VerificationMethod::Http,
4641 300,
4642 )
4643 .await
4644 .unwrap();
4645 store
4646 .mark_domain_verified(
4647 ProjectRef::DEFAULT,
4648 &SiteName::new("blog"),
4649 "www.example.com",
4650 )
4651 .await
4652 .unwrap();
4653 let config = store
4654 .attach_verified_domain(
4655 ProjectRef::DEFAULT,
4656 &SiteName::new("blog"),
4657 "www.example.com",
4658 )
4659 .await
4660 .unwrap();
4661 assert_eq!(config.domains.primary.as_deref(), Some("example.com"));
4662 assert_eq!(config.domains.aliases, vec!["www.example.com".to_string()]);
4663
4664 store
4666 .start_domain_verification(
4667 ProjectRef::DEFAULT,
4668 &SiteName::new("blog"),
4669 "*.example.com",
4670 VerificationMethod::Dns,
4671 400,
4672 )
4673 .await
4674 .unwrap();
4675 assert!(store
4677 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4678 .await
4679 .unwrap());
4680 let config = store
4681 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4682 .await
4683 .unwrap();
4684 assert_eq!(config.domains.wildcards, vec!["*.example.com".to_string()]);
4685
4686 assert_eq!(
4688 store
4689 .list_domain_verifications(ProjectRef::DEFAULT, &SiteName::new("blog"))
4690 .await
4691 .unwrap()
4692 .len(),
4693 2
4694 );
4695 assert!(store
4696 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4697 .await
4698 .unwrap());
4699 assert!(!store
4700 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4701 .await
4702 .unwrap());
4703 }
4704
4705 #[tokio::test]
4709 async fn host_cannot_be_hijacked_across_sites() {
4710 use crate::config::{DomainConfig, SiteConfig};
4711 use crate::domain_verify::VerificationMethod;
4712
4713 let store = store();
4714
4715 store
4717 .start_domain_verification(
4718 ProjectRef::DEFAULT,
4719 &SiteName::new("a"),
4720 "shared.example",
4721 VerificationMethod::Http,
4722 100,
4723 )
4724 .await
4725 .unwrap();
4726 store
4727 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4728 .await
4729 .unwrap();
4730 store
4731 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4732 .await
4733 .unwrap();
4734 assert_eq!(
4735 store
4736 .resolve_site_by_host("shared.example")
4737 .await
4738 .unwrap()
4739 .map(|o| o.site)
4740 .as_deref(),
4741 Some("a")
4742 );
4743
4744 store
4748 .start_domain_verification(
4749 ProjectRef::DEFAULT,
4750 &SiteName::new("b"),
4751 "shared.example",
4752 VerificationMethod::Http,
4753 200,
4754 )
4755 .await
4756 .unwrap();
4757 store
4758 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4759 .await
4760 .unwrap();
4761 let err = store
4762 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4763 .await
4764 .expect_err("second site must not hijack an attached host");
4765 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4766
4767 let stolen = SiteConfig {
4770 domains: DomainConfig {
4771 primary: Some("shared.example".into()),
4772 ..Default::default()
4773 },
4774 ..Default::default()
4775 };
4776 let err = store
4777 .set_site_config(ProjectRef::DEFAULT, "b", &stolen)
4778 .await
4779 .expect_err("set_site_config must refuse another site's host");
4780 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4781
4782 assert_eq!(
4784 store
4785 .resolve_site_by_host("shared.example")
4786 .await
4787 .unwrap()
4788 .map(|o| o.site)
4789 .as_deref(),
4790 Some("a")
4791 );
4792
4793 let readd = SiteConfig {
4796 domains: DomainConfig {
4797 primary: Some("shared.example".into()),
4798 aliases: vec!["www.shared.example".into()],
4799 ..Default::default()
4800 },
4801 ..Default::default()
4802 };
4803 store
4804 .set_site_config(ProjectRef::DEFAULT, "a", &readd)
4805 .await
4806 .unwrap();
4807 assert_eq!(
4808 store
4809 .resolve_site_by_host("www.shared.example")
4810 .await
4811 .unwrap()
4812 .map(|o| o.site)
4813 .as_deref(),
4814 Some("a")
4815 );
4816 }
4817
4818 #[tokio::test]
4822 async fn domain_context_tag_is_written_and_inherited() {
4823 use crate::config::{DomainConfig, SiteConfig};
4824
4825 let store = store();
4826 let cfg = SiteConfig {
4827 domains: DomainConfig {
4828 primary: Some("acme-store.com".into()),
4829 aliases: vec!["www.acme-store.com".into()],
4830 wildcards: vec!["*.globex-store.com".into()],
4831 contexts: std::collections::BTreeMap::from([
4832 ("acme-store.com".into(), "acme".into()),
4833 ("*.globex-store.com".into(), "globex".into()),
4834 ]),
4835 ..Default::default()
4836 },
4837 ..Default::default()
4838 };
4839 store
4840 .set_site_config(ProjectRef::DEFAULT, "shop", &cfg)
4841 .await
4842 .unwrap();
4843
4844 async fn ctx(store: &DeployStore, host: &str) -> Option<String> {
4845 store
4846 .resolve_site_by_host(host)
4847 .await
4848 .unwrap()
4849 .and_then(|o| o.context)
4850 }
4851 assert_eq!(ctx(&store, "acme-store.com").await.as_deref(), Some("acme"));
4854 assert_eq!(
4855 ctx(&store, "www.acme-store.com").await.as_deref(),
4856 Some("acme")
4857 );
4858 assert_eq!(
4859 ctx(&store, "tenant7.globex-store.com").await.as_deref(),
4860 Some("globex")
4861 );
4862 }
4863
4864 #[tokio::test]
4868 async fn host_uniqueness_is_case_and_dot_insensitive() {
4869 use crate::config::{DomainConfig, SiteConfig};
4870 use crate::domain_verify::VerificationMethod;
4871
4872 let store = store();
4873 store
4875 .start_domain_verification(
4876 ProjectRef::DEFAULT,
4877 &SiteName::new("a"),
4878 "example.com",
4879 VerificationMethod::Http,
4880 100,
4881 )
4882 .await
4883 .unwrap();
4884 store
4885 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4886 .await
4887 .unwrap();
4888 store
4889 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4890 .await
4891 .unwrap();
4892
4893 for variant in ["Example.COM", "example.com.", "EXAMPLE.com."] {
4895 let cfg = SiteConfig {
4896 domains: DomainConfig {
4897 primary: Some(variant.into()),
4898 ..Default::default()
4899 },
4900 ..Default::default()
4901 };
4902 let err = store
4903 .set_site_config(ProjectRef::DEFAULT, "b", &cfg)
4904 .await
4905 .expect_err("variant claim must be refused");
4906 assert!(
4907 matches!(err, DeployError::Conflict(_)),
4908 "variant {variant:?} must Conflict, got {err:?}"
4909 );
4910 }
4911
4912 for h in ["example.com", "Example.com", "EXAMPLE.COM", "example.com."] {
4914 assert_eq!(
4915 store
4916 .resolve_site_by_host(h)
4917 .await
4918 .unwrap()
4919 .map(|o| o.site)
4920 .as_deref(),
4921 Some("a"),
4922 "host {h:?} must resolve to site a"
4923 );
4924 }
4925 }
4926
4927 #[tokio::test]
4931 async fn self_serve_challenge_lookup_matches_pending_http_only() {
4932 use crate::domain_verify::{VerificationMethod, CHALLENGE_TTL_SECS};
4933
4934 let store = store();
4935 let v = store
4936 .start_domain_verification(
4937 ProjectRef::DEFAULT,
4938 &SiteName::new("docs"),
4939 "docs.example",
4940 VerificationMethod::Http,
4941 1_000,
4942 )
4943 .await
4944 .unwrap();
4945
4946 let found = store
4948 .find_pending_http_challenge("docs.example", &v.token, 1_000)
4949 .await
4950 .unwrap();
4951 assert_eq!(
4952 found.as_ref().map(|f| f.token.clone()),
4953 Some(v.token.clone())
4954 );
4955 assert!(store
4957 .find_pending_http_challenge("Docs.Example.", &v.token, 1_000)
4958 .await
4959 .unwrap()
4960 .is_some());
4961
4962 assert!(store
4964 .find_pending_http_challenge("docs.example", "not-the-token", 1_000)
4965 .await
4966 .unwrap()
4967 .is_none());
4968 assert!(store
4969 .find_pending_http_challenge("other.example", &v.token, 1_000)
4970 .await
4971 .unwrap()
4972 .is_none());
4973
4974 assert!(store
4976 .find_pending_http_challenge("docs.example", &v.token, 1_000 + CHALLENGE_TTL_SECS + 1)
4977 .await
4978 .unwrap()
4979 .is_none());
4980
4981 let dv = store
4983 .start_domain_verification(
4984 ProjectRef::DEFAULT,
4985 &SiteName::new("dns-site"),
4986 "dns.example",
4987 VerificationMethod::Dns,
4988 1_000,
4989 )
4990 .await
4991 .unwrap();
4992 assert!(store
4993 .find_pending_http_challenge("dns.example", &dv.token, 1_000)
4994 .await
4995 .unwrap()
4996 .is_none());
4997 }
4998
4999 #[tokio::test]
5003 async fn wildcard_requires_dns_and_stale_index_is_safe() {
5004 use crate::domain_verify::VerificationMethod;
5005
5006 let store = store();
5007
5008 let http = store
5010 .start_domain_verification(
5011 ProjectRef::DEFAULT,
5012 &SiteName::new("s"),
5013 "*.example.com",
5014 VerificationMethod::Http,
5015 100,
5016 )
5017 .await
5018 .unwrap();
5019 store
5020 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5021 .await
5022 .unwrap();
5023 let err = store
5024 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5025 .await
5026 .expect_err("wildcard with only HTTP proof must be refused");
5027 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
5028
5029 store
5033 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5034 .await
5035 .unwrap();
5036 assert!(store
5038 .find_pending_http_challenge("example.com", &http.token, 100)
5039 .await
5040 .unwrap()
5041 .is_none());
5042 store
5043 .start_domain_verification(
5044 ProjectRef::DEFAULT,
5045 &SiteName::new("s"),
5046 "*.example.com",
5047 VerificationMethod::Dns,
5048 200,
5049 )
5050 .await
5051 .unwrap();
5052 store
5053 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5054 .await
5055 .unwrap();
5056 let cfg = store
5057 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5058 .await
5059 .unwrap();
5060 assert_eq!(cfg.domains.wildcards, vec!["*.example.com".to_string()]);
5061
5062 let h2 = store
5066 .start_domain_verification(
5067 ProjectRef::DEFAULT,
5068 &SiteName::new("s2"),
5069 "host.example",
5070 VerificationMethod::Http,
5071 300,
5072 )
5073 .await
5074 .unwrap();
5075 assert!(store
5076 .find_pending_http_challenge("host.example", &h2.token, 300)
5077 .await
5078 .unwrap()
5079 .is_some());
5080 store
5081 .start_domain_verification(
5082 ProjectRef::DEFAULT,
5083 &SiteName::new("s2"),
5084 "host.example",
5085 VerificationMethod::Dns,
5086 300,
5087 )
5088 .await
5089 .unwrap();
5090 assert!(
5091 store
5092 .find_pending_http_challenge("host.example", &h2.token, 300)
5093 .await
5094 .unwrap()
5095 .is_none(),
5096 "a stale HTTP index must not serve a token whose record is now DNS"
5097 );
5098 }
5099
5100 #[tokio::test]
5101 async fn pending_verifications_are_capped_per_site() {
5102 let store = store();
5103 for i in 0..64 {
5105 store
5106 .start_domain_verification(
5107 ProjectRef::DEFAULT,
5108 &SiteName::new("site"),
5109 &format!("h{i}.example"),
5110 VerificationMethod::Http,
5111 100,
5112 )
5113 .await
5114 .unwrap();
5115 }
5116 let err = store
5118 .start_domain_verification(
5119 ProjectRef::DEFAULT,
5120 &SiteName::new("site"),
5121 "overflow.example",
5122 VerificationMethod::Http,
5123 100,
5124 )
5125 .await
5126 .expect_err("the 65th pending host must be rejected");
5127 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
5128 store
5130 .start_domain_verification(
5131 ProjectRef::DEFAULT,
5132 &SiteName::new("site"),
5133 "h0.example",
5134 VerificationMethod::Http,
5135 100,
5136 )
5137 .await
5138 .expect("re-running an existing challenge is not capped");
5139 store
5141 .start_domain_verification(
5142 ProjectRef::DEFAULT,
5143 &SiteName::new("other"),
5144 "fresh.example",
5145 VerificationMethod::Http,
5146 100,
5147 )
5148 .await
5149 .expect("a different site is unaffected");
5150 }
5151
5152 fn store() -> DeployStore {
5153 use crate::kv::MemoryKv;
5154 DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
5155 }
5156
5157 #[tokio::test]
5158 async fn default_project_is_visible_on_a_fresh_store_and_ensure_is_idempotent() {
5159 let s = store();
5160 let default = crate::project::DEFAULT_PROJECT;
5161
5162 assert!(
5164 s.get_project(default).await.unwrap().is_some(),
5165 "`project show default` must never 404, even on a fresh store"
5166 );
5167 let listed = s.list_projects().await.unwrap();
5168 assert_eq!(
5169 listed.iter().filter(|p| p.name == default).count(),
5170 1,
5171 "`project ls` must show exactly one `default` on a fresh store"
5172 );
5173 assert!(s.get_project("nope").await.unwrap().is_none());
5175
5176 assert!(s.project_exists(default).await.unwrap());
5179 assert!(!s.project_exists("nope").await.unwrap());
5180
5181 assert!(
5183 s.ensure_default_project().await.unwrap(),
5184 "first ensure creates"
5185 );
5186 assert!(
5187 !s.ensure_default_project().await.unwrap(),
5188 "second ensure is idempotent (presence-checked)"
5189 );
5190
5191 let listed = s.list_projects().await.unwrap();
5193 assert_eq!(
5194 listed.iter().filter(|p| p.name == default).count(),
5195 1,
5196 "materializing `default` must not duplicate it in the listing"
5197 );
5198 assert_eq!(s.get_project(default).await.unwrap().unwrap().name, default);
5199 }
5200
5201 #[tokio::test]
5202 async fn daemon_config_store_round_trips_and_rolls_back() {
5203 use crate::daemon_config::DaemonConfig;
5204 let s = store();
5205 assert!(s.get_daemon_config().await.unwrap().is_none());
5207 assert!(s.daemon_config_generation().await.unwrap().is_none());
5208
5209 let g1cfg = DaemonConfig {
5211 default_site: Some("one".into()),
5212 ..Default::default()
5213 };
5214 let g1 = s.set_daemon_config(&g1cfg).await.unwrap();
5215 assert_eq!(
5216 s.daemon_config_generation().await.unwrap().as_deref(),
5217 Some(g1.as_str())
5218 );
5219 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
5220 assert!(s.daemon_config_history().await.unwrap().is_empty());
5221
5222 let g2cfg = DaemonConfig {
5224 default_site: Some("two".into()),
5225 ..Default::default()
5226 };
5227 let g2 = s.set_daemon_config(&g2cfg).await.unwrap();
5228 assert_ne!(g1, g2);
5229 assert_eq!(s.daemon_config_history().await.unwrap(), vec![g1.clone()]);
5230
5231 let rolled = s.rollback_daemon_config().await.unwrap();
5233 assert_eq!(rolled.as_deref(), Some(g1.as_str()));
5234 assert_eq!(
5235 s.daemon_config_generation().await.unwrap().as_deref(),
5236 Some(g1.as_str())
5237 );
5238 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
5239 assert!(s.rollback_daemon_config().await.unwrap().is_none());
5241 }
5242
5243 #[tokio::test]
5244 async fn compute_store_round_trips() {
5245 use crate::compute::{
5246 ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
5247 };
5248 let s = store();
5249 let spec = ComputeSpec {
5250 version: crate::SCHEMA_VERSION,
5251 root: RootSource::Rootfs("r".repeat(64)),
5252 kernel: "k".repeat(64),
5253 kernel_cmdline: None,
5254 vcpus: 1,
5255 mem_mib: 256,
5256 entrypoint: vec!["/app".into()],
5257 env: Default::default(),
5258 port: 8080,
5259 restart: RestartPolicy::Always,
5260 startup_grace_secs: 30,
5261 scale_to_zero: false,
5262 volumes: vec![],
5263 writable_root: false,
5264 cap_add: Vec::new(),
5265 user: None,
5266 isolation: Default::default(),
5267 prefer_backend: None,
5268 bindings: vec![],
5269 };
5270 let hash = s.put_compute_spec(&spec).await.unwrap();
5272 assert_eq!(hash, spec.id());
5273 assert_eq!(s.get_compute_spec(&hash).await.unwrap(), Some(spec));
5274 assert!(s.get_compute_spec("deadbeef").await.unwrap().is_none());
5275
5276 let workload = ComputeWorkload {
5278 version: crate::SCHEMA_VERSION,
5279 name: "api".into(),
5280 active: hash.clone(),
5281 replicas: 3,
5282 placement: PlacementConstraints::default(),
5283 };
5284 s.set_compute_workload(ProjectRef::DEFAULT, &workload)
5285 .await
5286 .unwrap();
5287 assert_eq!(
5288 s.get_compute_workload(ProjectRef::DEFAULT, "api")
5289 .await
5290 .unwrap(),
5291 Some(workload)
5292 );
5293 assert_eq!(
5294 s.list_compute_workloads(ProjectRef::DEFAULT)
5295 .await
5296 .unwrap()
5297 .len(),
5298 1
5299 );
5300 assert!(s
5301 .delete_compute_workload(ProjectRef::DEFAULT, "api")
5302 .await
5303 .unwrap());
5304 assert!(!s
5305 .delete_compute_workload(ProjectRef::DEFAULT, "api")
5306 .await
5307 .unwrap());
5308 assert!(s
5309 .list_compute_workloads(ProjectRef::DEFAULT)
5310 .await
5311 .unwrap()
5312 .is_empty());
5313 }
5314
5315 fn empty_manifest(clean_urls: bool) -> Manifest {
5318 Manifest {
5319 config: DeployConfig {
5320 clean_urls,
5321 ..DeployConfig::default()
5322 },
5323 ..Default::default()
5324 }
5325 }
5326
5327 #[tokio::test]
5328 async fn deploy_meta_records_sizes_and_merges_provenance() {
5329 let store = store();
5330 let mut manifest = Manifest::default();
5331 manifest.files.insert("index.html".into(), {
5332 let mut e = entry("aa");
5333 e.size = 10;
5334 e
5335 });
5336 manifest.files.insert("app.js".into(), {
5337 let mut e = entry("bb");
5338 e.size = 32;
5339 e
5340 });
5341
5342 let id = store
5344 .put_manifest_with(
5345 &manifest,
5346 DeployMetaInput {
5347 source: Some("abc123".into()),
5348 message: Some("first".into()),
5349 ..Default::default()
5350 },
5351 )
5352 .await
5353 .unwrap();
5354 let meta = store.get_meta(&id).await.unwrap().unwrap();
5355 assert_eq!(meta.file_count, 2);
5356 assert_eq!(meta.total_size, 42);
5357 assert_eq!(meta.source.as_deref(), Some("abc123"));
5358 let created = meta.created_at;
5359
5360 store
5362 .put_manifest_with(&manifest, DeployMetaInput::default())
5363 .await
5364 .unwrap();
5365 let meta = store.get_meta(&id).await.unwrap().unwrap();
5366 assert_eq!(meta.created_at, created);
5367 assert_eq!(meta.source.as_deref(), Some("abc123"));
5368 assert_eq!(meta.message.as_deref(), Some("first"));
5369 }
5370
5371 #[tokio::test]
5372 async fn aliases_round_trip_and_guard_completeness() {
5373 let store = store();
5374 let manifest = empty_manifest(false);
5375 let id = store.put_manifest(&manifest).await.unwrap();
5376
5377 assert!(matches!(
5379 store
5380 .set_alias(ProjectRef::DEFAULT, "blog", "staging", "deadbeef")
5381 .await,
5382 Err(DeployError::NotFound(_))
5383 ));
5384
5385 store
5386 .set_alias(ProjectRef::DEFAULT, "blog", "staging", &id)
5387 .await
5388 .unwrap();
5389 assert_eq!(
5390 store
5391 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
5392 .await
5393 .unwrap(),
5394 Some(id.clone())
5395 );
5396 let aliases = store
5397 .list_aliases(ProjectRef::DEFAULT, "blog")
5398 .await
5399 .unwrap();
5400 assert_eq!(aliases.get("staging"), Some(&id));
5401
5402 assert!(store
5403 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
5404 .await
5405 .unwrap());
5406 assert!(!store
5407 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
5408 .await
5409 .unwrap());
5410 assert_eq!(
5411 store
5412 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
5413 .await
5414 .unwrap(),
5415 None
5416 );
5417 }
5418
5419 #[tokio::test]
5420 async fn retention_keep_last_collects_older_history() {
5421 let store = store();
5422 let m1 = empty_manifest(false);
5424 let m2 = empty_manifest(true);
5425 let mut m3 = empty_manifest(true);
5426 m3.config.trailing_slash = crate::config::TrailingSlash::Always;
5427 let id1 = store.put_manifest(&m1).await.unwrap();
5428 let id2 = store.put_manifest(&m2).await.unwrap();
5429 let id3 = store.put_manifest(&m3).await.unwrap();
5430 store
5431 .activate(ProjectRef::DEFAULT, "blog", &id1)
5432 .await
5433 .unwrap();
5434 store
5435 .activate(ProjectRef::DEFAULT, "blog", &id2)
5436 .await
5437 .unwrap();
5438 store
5439 .activate(ProjectRef::DEFAULT, "blog", &id3)
5440 .await
5441 .unwrap();
5442
5443 let report = store.collect_garbage(false).await.unwrap();
5445 assert_eq!(report.manifests_removed, 0);
5446
5447 store
5450 .set_alias(ProjectRef::DEFAULT, "blog", "pinned", &id1)
5451 .await
5452 .unwrap();
5453 let report = store
5454 .collect_garbage_with(
5455 false,
5456 GcOptions {
5457 keep_last: Some(1),
5458 ..Default::default()
5459 },
5460 )
5461 .await
5462 .unwrap();
5463 assert_eq!(report.manifests_removed, 1); }
5465
5466 #[tokio::test]
5467 async fn grace_window_protects_in_flight_manifest() {
5468 let store = store();
5469 let id = store.put_manifest(&empty_manifest(false)).await.unwrap();
5471 assert!(store.get_manifest(&id).await.unwrap().is_some());
5472
5473 let report = store
5475 .collect_garbage_with(
5476 false,
5477 GcOptions {
5478 grace_secs: 3600,
5479 ..Default::default()
5480 },
5481 )
5482 .await
5483 .unwrap();
5484 assert_eq!(report.manifests_removed, 0);
5485
5486 let report = store.collect_garbage(false).await.unwrap();
5488 assert_eq!(report.manifests_removed, 1);
5489 }
5490
5491 #[tokio::test]
5492 async fn resolve_manifest_id_exact_prefix_and_missing() {
5493 let store = store();
5494 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
5495
5496 assert_eq!(
5498 store.resolve_manifest_id(&id).await.unwrap().as_deref(),
5499 Some(id.as_str())
5500 );
5501 assert_eq!(
5503 store
5504 .resolve_manifest_id(&id[..16])
5505 .await
5506 .unwrap()
5507 .as_deref(),
5508 Some(id.as_str())
5509 );
5510 assert!(store
5512 .resolve_manifest_id("ffffffffffffffff")
5513 .await
5514 .unwrap()
5515 .is_none());
5516 }
5517
5518 #[tokio::test]
5519 async fn delete_site_removes_config_routing_and_aliases() {
5520 let store = store();
5521 let mut cfg = SiteConfig::default();
5522 cfg.domains.primary = Some("blog.example".into());
5523 cfg.domains.wildcards = vec!["*.preview.blog.example".into()];
5524 store
5525 .set_site_config(ProjectRef::DEFAULT, "blog", &cfg)
5526 .await
5527 .unwrap();
5528 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
5530 store
5531 .set_alias(ProjectRef::DEFAULT, "blog", "stable", &id)
5532 .await
5533 .unwrap();
5534
5535 assert!(store
5537 .get_site_config(ProjectRef::DEFAULT, "blog")
5538 .await
5539 .unwrap()
5540 .is_some());
5541 assert_eq!(
5542 store
5543 .resolve_site_by_host("blog.example")
5544 .await
5545 .unwrap()
5546 .map(|o| o.site)
5547 .as_deref(),
5548 Some("blog")
5549 );
5550
5551 store
5552 .delete_site(ProjectRef::DEFAULT, "blog")
5553 .await
5554 .unwrap();
5555
5556 assert!(store
5558 .get_site_config(ProjectRef::DEFAULT, "blog")
5559 .await
5560 .unwrap()
5561 .is_none());
5562 assert!(store
5563 .resolve_site_by_host("blog.example")
5564 .await
5565 .unwrap()
5566 .is_none());
5567 assert!(store
5568 .list_aliases(ProjectRef::DEFAULT, "blog")
5569 .await
5570 .unwrap()
5571 .is_empty());
5572
5573 store
5575 .delete_site(ProjectRef::DEFAULT, "blog")
5576 .await
5577 .unwrap();
5578 }
5579
5580 #[tokio::test]
5581 async fn gc_blob_reachability_is_a_cross_project_union() {
5582 use crate::kv::MemoryKv;
5583 let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
5585
5586 let shared = sha256_hex(b"shared-blob");
5590 let keep = sha256_hex(b"keep-blob");
5591 let dead = sha256_hex(b"dead-blob");
5592 store
5593 .put_blob(&shared, once_bytes(b"shared-blob"))
5594 .await
5595 .unwrap();
5596 store
5597 .put_blob(&keep, once_bytes(b"keep-blob"))
5598 .await
5599 .unwrap();
5600 store
5601 .put_blob(&dead, once_bytes(b"dead-blob"))
5602 .await
5603 .unwrap();
5604
5605 let m_shared = manifest_with(&[("index.html", &shared)]);
5606 let m_keep = manifest_with(&[("index.html", &keep)]);
5607 let m_dead = manifest_with(&[("old.html", &dead)]);
5608 let id_shared = store.put_manifest(&m_shared).await.unwrap();
5609 let id_keep = store.put_manifest(&m_keep).await.unwrap();
5610 let id_dead = store.put_manifest(&m_dead).await.unwrap();
5611
5612 let acme = ProjectRef::new("acme");
5614 store.activate(acme, "site", &id_shared).await.unwrap();
5615 let shop = ProjectRef::new("shop");
5619 store.activate(shop, "site", &id_shared).await.unwrap();
5620 store.activate(shop, "site", &id_keep).await.unwrap();
5621 let report = store
5624 .collect_garbage_with(
5625 true,
5626 GcOptions {
5627 keep_last: Some(1),
5628 ..Default::default()
5629 },
5630 )
5631 .await
5632 .unwrap();
5633
5634 assert!(store.get_manifest(&id_dead).await.unwrap().is_none());
5636 assert!(
5637 !store.has_blob(&dead).await.unwrap(),
5638 "dead blob is reclaimed"
5639 );
5640 assert_eq!(report.manifests_removed, 1, "only the dead manifest goes");
5641
5642 assert!(
5645 store.get_manifest(&id_shared).await.unwrap().is_some(),
5646 "shared manifest kept by acme's reference"
5647 );
5648 assert!(
5649 store.has_blob(&shared).await.unwrap(),
5650 "shared blob kept — reachability is the union across projects"
5651 );
5652 assert!(store.has_blob(&keep).await.unwrap());
5654 assert_eq!(
5655 store.current_id(acme, "site").await.unwrap().as_deref(),
5656 Some(id_shared.as_str())
5657 );
5658 }
5659
5660 #[tokio::test]
5661 async fn same_site_name_in_two_projects_is_isolated() {
5662 use crate::kv::MemoryKv;
5663 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
5664 let acme = ProjectRef::new("acme");
5665 let shop = ProjectRef::new("shop");
5666
5667 let id_a = store.put_manifest(&empty_manifest(false)).await.unwrap();
5669 let id_b = store.put_manifest(&empty_manifest(true)).await.unwrap();
5670 store.activate(acme, "www", &id_a).await.unwrap();
5671 store.activate(shop, "www", &id_b).await.unwrap();
5672
5673 assert_eq!(
5675 store.current_id(acme, "www").await.unwrap().as_deref(),
5676 Some(id_a.as_str())
5677 );
5678 assert_eq!(
5679 store.current_id(shop, "www").await.unwrap().as_deref(),
5680 Some(id_b.as_str())
5681 );
5682 assert_ne!(id_a, id_b);
5683
5684 let mut cfg_a = SiteConfig::default();
5686 cfg_a.domains.primary = Some("acme.example".into());
5687 store.set_site_config(acme, "www", &cfg_a).await.unwrap();
5688 let mut cfg_b = SiteConfig::default();
5689 cfg_b.domains.primary = Some("shop.example".into());
5690 store.set_site_config(shop, "www", &cfg_b).await.unwrap();
5691 store
5692 .set_alias(acme, "www", "staging", &id_a)
5693 .await
5694 .unwrap();
5695
5696 assert_eq!(
5697 store
5698 .get_site_config(acme, "www")
5699 .await
5700 .unwrap()
5701 .unwrap()
5702 .domains
5703 .primary
5704 .as_deref(),
5705 Some("acme.example")
5706 );
5707 assert_eq!(
5708 store
5709 .get_site_config(shop, "www")
5710 .await
5711 .unwrap()
5712 .unwrap()
5713 .domains
5714 .primary
5715 .as_deref(),
5716 Some("shop.example")
5717 );
5718 assert!(store
5720 .get_alias(acme, "www", "staging")
5721 .await
5722 .unwrap()
5723 .is_some());
5724 assert!(store
5725 .get_alias(shop, "www", "staging")
5726 .await
5727 .unwrap()
5728 .is_none());
5729 assert!(store.list_aliases(shop, "www").await.unwrap().is_empty());
5730
5731 assert_eq!(
5733 store.resolve_site_by_host("acme.example").await.unwrap(),
5734 Some(DomainOwner::new("acme", "www"))
5735 );
5736 assert_eq!(
5737 store.resolve_site_by_host("shop.example").await.unwrap(),
5738 Some(DomainOwner::new("shop", "www"))
5739 );
5740 store.delete_site(acme, "www").await.unwrap();
5741 assert!(store.get_site_config(acme, "www").await.unwrap().is_none());
5742 assert!(
5743 store.get_site_config(shop, "www").await.unwrap().is_some(),
5744 "deleting acme/www must not touch shop/www"
5745 );
5746 assert!(store.current_id(shop, "www").await.unwrap().is_some());
5747 }
5748
5749 #[tokio::test]
5750 async fn project_entity_crud_and_delete_guard() {
5751 use crate::project::Project;
5752 let store = store();
5753 let acme = Project {
5754 version: crate::SCHEMA_VERSION,
5755 name: "acme".into(),
5756 created_at: 1,
5757 meta: Default::default(),
5758 config: Default::default(),
5759 secrets_ref: None,
5760 };
5761 let hash = store.put_project(&acme).await.unwrap();
5763 assert_eq!(hash, acme.id());
5764 assert_eq!(store.get_project("acme").await.unwrap(), Some(acme.clone()));
5765 assert!(store.get_project("ghost").await.unwrap().is_none());
5766 let names: Vec<String> = store
5768 .list_projects()
5769 .await
5770 .unwrap()
5771 .into_iter()
5772 .map(|p| p.name)
5773 .collect();
5774 assert_eq!(names, vec!["acme".to_string(), "default".to_string()]);
5775
5776 store
5778 .set_site_config(ProjectRef::new("acme"), "www", &SiteConfig::default())
5779 .await
5780 .unwrap();
5781 assert!(matches!(
5782 store.delete_project("acme").await,
5783 Err(DeployError::Conflict(_))
5784 ));
5785 store
5787 .delete_site(ProjectRef::new("acme"), "www")
5788 .await
5789 .unwrap();
5790 assert!(store.delete_project("acme").await.unwrap());
5791 assert!(store.get_project("acme").await.unwrap().is_none());
5792
5793 assert!(matches!(
5795 store.delete_project("default").await,
5796 Err(DeployError::Conflict(_))
5797 ));
5798 }
5799
5800 #[tokio::test]
5801 async fn open_blob_cached_caches_small_and_streams_large() {
5802 use crate::kv::MemoryKv;
5803 let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
5804
5805 let small = b"hello, static hot path";
5808 let small_hash = sha256_hex(small);
5809 store
5810 .put_blob(&small_hash, once_bytes(small))
5811 .await
5812 .unwrap();
5813 match store
5814 .open_blob_cached(&small_hash, small.len() as u64)
5815 .await
5816 .unwrap()
5817 {
5818 BlobBody::Cached(bytes) => assert_eq!(&bytes[..], small),
5819 BlobBody::Stream(_) => panic!("small blob should be cached, not streamed"),
5820 BlobBody::Mapped(_) => panic!("small blob should be cached, not mapped"),
5821 }
5822 {
5823 let cache = store.blob_body_cache.read().unwrap();
5824 assert!(cache.map.contains_key(&small_hash));
5825 assert_eq!(cache.bytes, small.len());
5826 }
5827 assert!(matches!(
5828 store
5829 .open_blob_cached(&small_hash, small.len() as u64)
5830 .await
5831 .unwrap(),
5832 BlobBody::Cached(_)
5833 ));
5834
5835 let large = vec![7u8; SMALL_BLOB_CACHE_MAX as usize + 1];
5837 let large_hash = sha256_hex(&large);
5838 let put_body = {
5839 let large = large.clone();
5840 futures::stream::once(async move { Ok(bytes::Bytes::from(large)) }).boxed()
5841 };
5842 store.put_blob(&large_hash, put_body).await.unwrap();
5843 match store
5844 .open_blob_cached(&large_hash, large.len() as u64)
5845 .await
5846 .unwrap()
5847 {
5848 BlobBody::Stream(object) => {
5849 let mut body = object.body;
5850 let mut got = Vec::new();
5851 while let Some(chunk) = body.next().await {
5852 got.extend_from_slice(&chunk.unwrap());
5853 }
5854 assert_eq!(got, large);
5855 }
5856 BlobBody::Cached(_) => panic!("large blob must stream, not cache"),
5857 BlobBody::Mapped(_) => panic!("MemStorage can't memory-map; expected a stream"),
5859 }
5860 assert!(!store
5861 .blob_body_cache
5862 .read()
5863 .unwrap()
5864 .map
5865 .contains_key(&large_hash));
5866 }
5867
5868 #[tokio::test]
5869 async fn enumerate_and_purge_project_are_scoped() {
5870 use crate::compute::{
5871 ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
5872 VolumeRef,
5873 };
5874 use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
5875 use crate::kv::MemoryKv;
5876 use crate::project::Project;
5877
5878 let kv = Arc::new(MemoryKv::new());
5879 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
5880 let acme = ProjectRef::new("acme");
5881 let other = ProjectRef::new("other");
5882
5883 store
5885 .put_project(&Project {
5886 version: crate::SCHEMA_VERSION,
5887 name: "acme".into(),
5888 created_at: 1,
5889 meta: Default::default(),
5890 config: Default::default(),
5891 secrets_ref: None,
5892 })
5893 .await
5894 .unwrap();
5895
5896 let mut cfg = SiteConfig::default();
5898 cfg.domains.primary = Some("acme.example".into());
5899 store.set_site_config(acme, "www", &cfg).await.unwrap();
5900 kv.put(
5902 &keys::domain("acme.example"),
5903 crate::project::DomainOwner::new("acme", "www").to_bytes(),
5904 )
5905 .await
5906 .unwrap();
5907
5908 store
5910 .put_function(
5911 acme,
5912 &Function::new(
5913 "worker",
5914 Owner::Project("acme".into()),
5915 "component-hash",
5916 FunctionConfig::default(),
5917 Lifecycle::default(),
5918 0,
5919 ),
5920 )
5921 .await
5922 .unwrap();
5923
5924 let spec = ComputeSpec {
5926 version: crate::SCHEMA_VERSION,
5927 root: RootSource::Rootfs("r".repeat(64)),
5928 kernel: "k".repeat(64),
5929 kernel_cmdline: None,
5930 vcpus: 1,
5931 mem_mib: 256,
5932 entrypoint: vec!["/app".into()],
5933 env: Default::default(),
5934 port: 8080,
5935 restart: RestartPolicy::Always,
5936 startup_grace_secs: 30,
5937 scale_to_zero: false,
5938 volumes: vec![VolumeRef {
5939 mount: "/data".into(),
5940 name: "pg-data".into(),
5941 size_mib: 1024,
5942 }],
5943 writable_root: false,
5944 cap_add: Vec::new(),
5945 user: None,
5946 isolation: Default::default(),
5947 prefer_backend: None,
5948 bindings: vec![],
5949 };
5950 let hash = store.put_compute_spec(&spec).await.unwrap();
5951 store
5952 .set_compute_workload(
5953 acme,
5954 &ComputeWorkload {
5955 version: crate::SCHEMA_VERSION,
5956 name: "pg".into(),
5957 active: hash,
5958 replicas: 1,
5959 placement: PlacementConstraints::default(),
5960 },
5961 )
5962 .await
5963 .unwrap();
5964
5965 kv.put(&keys::secret(acme, "api-key"), b"sealed".to_vec())
5967 .await
5968 .unwrap();
5969 kv.put(
5971 &format!("{}deadbeef", keys::graphql_safelist_prefix(acme)),
5972 b"query{me}".to_vec(),
5973 )
5974 .await
5975 .unwrap();
5976 kv.put(
5977 &format!("{}users", keys::graphql_subgraph_prefix(acme)),
5978 b"type Query{me:ID}".to_vec(),
5979 )
5980 .await
5981 .unwrap();
5982 kv.put("graphql/acme/version", 3u64.to_be_bytes().to_vec())
5983 .await
5984 .unwrap();
5985 kv.put(
5987 &crate::project::owner_key(crate::project::owner_kind::COMPUTE, "pg"),
5988 b"acme".to_vec(),
5989 )
5990 .await
5991 .unwrap();
5992 kv.put(
5993 &crate::project::owner_key(crate::project::owner_kind::SITE, "elsewhere"),
5994 b"other".to_vec(),
5995 )
5996 .await
5997 .unwrap();
5998
5999 store
6001 .set_site_config(other, "shop", &SiteConfig::default())
6002 .await
6003 .unwrap();
6004 kv.put("siteconfig/shared-cas", b"body".to_vec())
6005 .await
6006 .unwrap();
6007
6008 let plan = store.enumerate_project_resources("acme").await.unwrap();
6010 assert_eq!(plan.project, "acme");
6011 assert_eq!(plan.sites, vec!["www".to_string()]);
6012 assert_eq!(plan.functions, vec!["worker".to_string()]);
6013 assert_eq!(plan.compute.len(), 1);
6014 assert_eq!(plan.compute[0].name, "pg");
6015 assert_eq!(plan.compute[0].volumes, vec!["pg-data".to_string()]);
6016 assert_eq!(plan.all_volumes(), vec!["pg-data".to_string()]);
6017 assert_eq!(plan.secrets, vec!["api-key".to_string()]);
6018 assert_eq!(plan.safelist, 1);
6019 assert_eq!(plan.subgraphs, vec!["users".to_string()]);
6020 assert!(!plan.is_empty());
6021
6022 assert!(store.get_project("acme").await.unwrap().is_some());
6024 assert!(store.get_site_config(acme, "www").await.unwrap().is_some());
6025
6026 store.delete_site(acme, "www").await.unwrap();
6028 store.delete_function(acme, "worker").await.unwrap();
6029 store.delete_compute_workload(acme, "pg").await.unwrap();
6030 let purged = store.purge_project("acme").await.unwrap();
6031 assert!(purged > 0, "purge removed residual keys");
6032
6033 assert!(store.get_project("acme").await.unwrap().is_none());
6035 assert!(kv
6036 .list_prefix(&crate::project::resource_prefix("acme"))
6037 .await
6038 .unwrap()
6039 .is_empty());
6040 assert!(kv.list_prefix("graphql/acme/").await.unwrap().is_empty());
6041 assert!(kv
6042 .list_prefix(&keys::graphql_safelist_prefix(acme))
6043 .await
6044 .unwrap()
6045 .is_empty());
6046 assert!(kv
6047 .get(&crate::project::pointer_key("acme"))
6048 .await
6049 .unwrap()
6050 .is_none());
6051 assert!(kv
6052 .get(&crate::project::history_key("acme"))
6053 .await
6054 .unwrap()
6055 .is_none());
6056 assert!(kv
6057 .get(&crate::project::owner_key(
6058 crate::project::owner_kind::COMPUTE,
6059 "pg"
6060 ))
6061 .await
6062 .unwrap()
6063 .is_none());
6064 assert!(kv
6066 .get(&keys::domain("acme.example"))
6067 .await
6068 .unwrap()
6069 .is_none());
6070
6071 assert!(store
6073 .get_site_config(other, "shop")
6074 .await
6075 .unwrap()
6076 .is_some());
6077 assert!(!kv
6078 .list_prefix(&crate::project::resource_prefix("other"))
6079 .await
6080 .unwrap()
6081 .is_empty());
6082 assert_eq!(
6083 kv.get(&crate::project::owner_key(
6084 crate::project::owner_kind::SITE,
6085 "elsewhere"
6086 ))
6087 .await
6088 .unwrap(),
6089 Some(b"other".to_vec())
6090 );
6091 assert!(kv.get("siteconfig/shared-cas").await.unwrap().is_some());
6092
6093 assert!(matches!(
6095 store.purge_project("default").await,
6096 Err(DeployError::Conflict(_))
6097 ));
6098 }
6099
6100 #[tokio::test]
6107 async fn read_backfills_project_into_a_legacy_replica_record() {
6108 use crate::compute::{replica_state_key, Endpoint, ReplicaPhase, Scheme, Snapshot};
6109 use crate::kv::MemoryKv;
6110 let kv = Arc::new(MemoryKv::new());
6111 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
6112
6113 let legacy = serde_json::json!({
6117 "handle": { "workload": "web", "replica": 0, "backend_ref": "10.0.0.5:8080" },
6118 "node": 1,
6119 "backend": "container",
6120 "endpoint": { "scheme": "http", "host": "10.0.0.5", "port": 8080 },
6121 "healthy": false,
6122 "phase": "Zero",
6123 "snapshot": { "workload": "web", "replica": 0, "data_ref": "img|/rootfs|10.0.0.5|8080" }
6124 });
6125 assert!(legacy["handle"].get("project").is_none());
6127 kv.put(
6128 &replica_state_key("acme", "web", 0),
6129 serde_json::to_vec(&legacy).unwrap(),
6130 )
6131 .await
6132 .unwrap();
6133
6134 let states = store
6136 .list_replica_states(ProjectRef::new("acme"), "web")
6137 .await
6138 .unwrap();
6139 assert_eq!(states.len(), 1);
6140 assert_eq!(
6141 states[0].handle.project, "acme",
6142 "handle project backfilled"
6143 );
6144 assert_eq!(states[0].handle.workload, "web");
6145 assert_eq!(
6146 states[0].snapshot.as_ref().unwrap().project,
6147 "acme",
6148 "parked snapshot's project backfilled too"
6149 );
6150 assert_eq!(states[0].phase, ReplicaPhase::Zero);
6151
6152 let all = store.list_all_replica_states().await.unwrap();
6154 assert_eq!(all.len(), 1);
6155 assert_eq!(all[0].handle.project, "acme");
6156
6157 store
6160 .set_replica_state(
6161 ProjectRef::new("beta"),
6162 &crate::compute::ObservedInstance {
6163 handle: crate::compute::InstanceHandle {
6164 project: "beta".into(),
6165 workload: "web".into(),
6166 replica: 0,
6167 backend_ref: "10.0.0.6:8080".into(),
6168 },
6169 node: 1,
6170 backend: "container".into(),
6171 endpoint: Endpoint {
6172 scheme: Scheme::Http,
6173 host: "10.0.0.6".into(),
6174 port: 8080,
6175 },
6176 region: None,
6177 healthy: true,
6178 started_at: None,
6179 phase: ReplicaPhase::Running,
6180 snapshot: None::<Snapshot>,
6181 },
6182 )
6183 .await
6184 .unwrap();
6185 let beta = store
6186 .list_replica_states(ProjectRef::new("beta"), "web")
6187 .await
6188 .unwrap();
6189 assert_eq!(beta[0].handle.project, "beta");
6190 }
6191}