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 is_blob_key(key: &str) -> bool {
86 match key.split_once('/') {
87 Some((shard, hash)) => {
88 shard.len() == 2
89 && hash.len() == 64
90 && shard.bytes().all(|b| b.is_ascii_hexdigit())
91 && hash.bytes().all(|b| b.is_ascii_hexdigit())
92 }
93 None => false,
94 }
95}
96
97#[derive(Clone)]
100pub struct DeployStore {
101 storage: Arc<dyn Storage>,
102 kv: Arc<dyn KvStore>,
103 domain_claim_lock: Arc<futures::lock::Mutex<()>>,
110}
111
112mod keys {
113 use crate::project::ProjectRef;
130
131 pub fn manifest(id: &str) -> String {
133 format!("manifests/{id}")
134 }
135
136 pub fn meta(id: &str) -> String {
138 format!("meta/{id}")
139 }
140
141 pub fn current(project: ProjectRef<'_>, site: &str) -> String {
143 format!("project/{project}/current/{site}")
144 }
145
146 pub fn current_prefix(project: ProjectRef<'_>) -> String {
148 format!("project/{project}/current/")
149 }
150
151 pub fn alias(project: ProjectRef<'_>, site: &str, name: &str) -> String {
153 format!("project/{project}/alias/{site}/{name}")
154 }
155
156 pub fn alias_prefix(project: ProjectRef<'_>, site: &str) -> String {
158 format!("project/{project}/alias/{site}/")
159 }
160
161 pub fn alias_project_prefix(project: ProjectRef<'_>) -> String {
163 format!("project/{project}/alias/")
164 }
165
166 pub fn blob(hash: &str) -> String {
168 if hash.len() >= 2 {
169 format!("{}/{}", &hash[..2], hash)
170 } else {
171 hash.to_string()
172 }
173 }
174
175 pub fn site_pointer(project: ProjectRef<'_>, site: &str) -> String {
179 format!("project/{project}/site/{site}")
180 }
181
182 pub fn site_prefix(project: ProjectRef<'_>) -> String {
184 format!("project/{project}/site/")
185 }
186
187 pub fn site_config_blob(hash: &str) -> String {
192 format!("siteconfig/{hash}")
193 }
194
195 pub fn domain(host: &str) -> String {
199 format!("domain/{}", super::canon_host(host))
200 }
201
202 pub fn wildcard(suffix: &str) -> String {
205 format!("wildcard/{}", super::canon_host(suffix))
206 }
207
208 pub fn domain_verification(project: ProjectRef<'_>, site: &str, host: &str) -> String {
211 format!(
212 "project/{project}/domainverify/{site}/{}",
213 crate::domain_verify::normalize_host(host)
214 )
215 }
216
217 pub fn domain_verification_prefix(project: ProjectRef<'_>, site: &str) -> String {
219 format!("project/{project}/domainverify/{site}/")
220 }
221
222 pub fn domain_verification_project_prefix(project: ProjectRef<'_>) -> String {
224 format!("project/{project}/domainverify/")
225 }
226
227 pub fn http_challenge_index(host: &str, token: &str) -> String {
233 format!(
234 "httpchallenge/{}/{token}",
235 crate::domain_verify::normalize_host(host)
236 )
237 }
238
239 pub fn daemon_config_blob(hash: &str) -> String {
242 format!("daemonconfig/{hash}")
243 }
244
245 pub fn history(project: ProjectRef<'_>, site: &str) -> String {
247 format!("project/{project}/history/{site}")
248 }
249
250 pub fn history_prefix(project: ProjectRef<'_>) -> String {
252 format!("project/{project}/history/")
253 }
254
255 pub const PROJECT_ROOT: &str = "project/";
259
260 pub fn project_of_key(key: &str) -> Option<&str> {
263 key.strip_prefix(PROJECT_ROOT)
264 .and_then(|rest| rest.split('/').next())
265 .filter(|p| !p.is_empty())
266 }
267}
268
269impl DeployStore {
270 pub fn new(storage: Arc<dyn Storage>, kv: Arc<dyn KvStore>) -> Self {
272 Self {
273 storage,
274 kv,
275 domain_claim_lock: Arc::new(futures::lock::Mutex::new(())),
276 }
277 }
278
279 pub async fn ready(&self) -> Result<(), DeployError> {
283 self.kv.get("__readyz_probe__").await?;
284 Ok(())
285 }
286
287 pub async fn put_manifest(&self, manifest: &Manifest) -> Result<String, DeployError> {
289 self.put_manifest_with(manifest, DeployMetaInput::default())
290 .await
291 }
292
293 pub async fn put_manifest_with(
301 &self,
302 manifest: &Manifest,
303 input: DeployMetaInput,
304 ) -> Result<String, DeployError> {
305 let id = manifest.id()?;
306
307 let existing = self.get_meta(&id).await?;
311 let created_at = existing
312 .as_ref()
313 .map(|m| m.created_at)
314 .unwrap_or_else(now_unix);
315 let meta = DeployMeta {
316 version: crate::SCHEMA_VERSION,
317 created_at,
318 file_count: manifest.files.len() as u64,
319 total_size: manifest.files.values().map(|entry| entry.size).sum(),
320 source: input
321 .source
322 .or_else(|| existing.as_ref().and_then(|m| m.source.clone())),
323 branch: input
324 .branch
325 .or_else(|| existing.as_ref().and_then(|m| m.branch.clone())),
326 author: input
327 .author
328 .or_else(|| existing.as_ref().and_then(|m| m.author.clone())),
329 message: input
330 .message
331 .or_else(|| existing.as_ref().and_then(|m| m.message.clone())),
332 tag: input
333 .tag
334 .or_else(|| existing.as_ref().and_then(|m| m.tag.clone())),
335 tags: if input.tags.is_empty() {
338 existing.map(|m| m.tags).unwrap_or_default()
339 } else {
340 input.tags
341 },
342 };
343 self.kv
344 .write_batch(vec![
345 WriteOp::Put(keys::manifest(&id), manifest.to_bytes()?),
346 WriteOp::Put(keys::meta(&id), serde_json::to_vec(&meta)?),
347 ])
348 .await?;
349 Ok(id)
350 }
351
352 pub async fn get_meta(&self, id: &str) -> Result<Option<DeployMeta>, DeployError> {
354 match self.kv.get(&keys::meta(id)).await? {
355 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
356 None => Ok(None),
357 }
358 }
359
360 pub async fn get_manifest(&self, id: &str) -> Result<Option<Manifest>, DeployError> {
362 match self.kv.get(&keys::manifest(id)).await? {
363 Some(bytes) => Ok(Some(Manifest::from_bytes(&bytes)?)),
364 None => Ok(None),
365 }
366 }
367
368 pub async fn resolve_manifest_id(&self, prefix: &str) -> Result<Option<String>, DeployError> {
375 if self.kv.get(&keys::manifest(prefix)).await?.is_some() {
377 return Ok(Some(prefix.to_string()));
378 }
379 let key_prefix = keys::manifest(prefix);
380 let strip = "manifests/".len();
381 let keys = self.kv.list_prefix(&key_prefix).await?;
382 let mut ids = keys.iter().map(|key| &key[strip..]);
383 match (ids.next(), ids.next()) {
384 (Some(only), None) => Ok(Some(only.to_string())),
385 (Some(_), Some(_)) => Err(DeployError::Ambiguous(prefix.to_string())),
386 _ => Ok(None),
387 }
388 }
389
390 pub async fn has_blob(&self, hash: &str) -> Result<bool, DeployError> {
392 match self.storage.head(&keys::blob(hash)).await {
393 Ok(_) => Ok(true),
394 Err(StorageError::NotFound(_)) => Ok(false),
395 Err(err) => Err(err.into()),
396 }
397 }
398
399 pub async fn missing_blobs(&self, manifest: &Manifest) -> Result<Vec<String>, DeployError> {
401 let mut missing = Vec::new();
402 for hash in manifest.blob_hashes() {
403 if !self.has_blob(&hash).await? {
404 missing.push(hash);
405 }
406 }
407 Ok(missing)
408 }
409
410 pub async fn put_blob(&self, hash: &str, body: ByteStream) -> Result<(), DeployError> {
415 let hasher = Arc::new(Mutex::new(Sha256::new()));
416 let tap = hasher.clone();
417 let verified: ByteStream = body
418 .map(move |chunk| {
419 if let Ok(bytes) = &chunk {
420 tap.lock().unwrap().update(bytes);
421 }
422 chunk
423 })
424 .boxed();
425
426 let key = keys::blob(hash);
427 self.storage.put(&key, verified, PutMeta::default()).await?;
428
429 let actual = hex::encode(hasher.lock().unwrap().clone().finalize());
430 if actual != hash {
431 let _ = self.storage.delete(&key).await;
432 return Err(DeployError::HashMismatch {
433 expected: hash.to_string(),
434 actual,
435 });
436 }
437 Ok(())
438 }
439
440 pub async fn open_blob(&self, hash: &str) -> Result<GetObject, DeployError> {
442 Ok(self.storage.get(&keys::blob(hash)).await?)
443 }
444
445 pub async fn open_blob_range(
447 &self,
448 hash: &str,
449 offset: u64,
450 len: Option<u64>,
451 ) -> Result<GetObject, DeployError> {
452 Ok(self
453 .storage
454 .get_range(&keys::blob(hash), offset, len)
455 .await?)
456 }
457
458 pub async fn get_site_config(
461 &self,
462 project: ProjectRef<'_>,
463 site: &str,
464 ) -> Result<Option<SiteConfig>, DeployError> {
465 let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
466 return Ok(None);
467 };
468 let hash = String::from_utf8_lossy(&hash).into_owned();
469 match self.kv.get(&keys::site_config_blob(&hash)).await? {
470 Some(bytes) => Ok(Some(SiteConfig::from_json(&bytes)?)),
471 None => Ok(None),
473 }
474 }
475
476 pub async fn set_site_config(
496 &self,
497 project: ProjectRef<'_>,
498 site: &str,
499 config: &SiteConfig,
500 ) -> Result<(), DeployError> {
501 let _claim = self.domain_claim_lock.lock().await;
502 self.set_site_config_locked(project, site, config).await
503 }
504
505 async fn set_site_config_locked(
510 &self,
511 project: ProjectRef<'_>,
512 site: &str,
513 config: &SiteConfig,
514 ) -> Result<(), DeployError> {
515 let owner = DomainOwner::new(project.as_str(), site);
516 for host in config.domains.exact_hosts() {
520 self.ensure_host_claimable(&keys::domain(host), host, &owner)
521 .await?;
522 }
523 for wildcard in &config.domains.wildcards {
524 if let Some(suffix) = wildcard.strip_prefix("*.") {
525 self.ensure_host_claimable(&keys::wildcard(suffix), wildcard, &owner)
526 .await?;
527 }
528 }
529
530 let body = config.to_json()?;
531 let hash = sha256_hex(&body);
532
533 let mut ops = Vec::new();
534 if let Some(old) = self.get_site_config(project, site).await? {
535 for host in old.domains.exact_hosts() {
536 ops.push(WriteOp::Delete(keys::domain(host)));
537 }
538 for wildcard in &old.domains.wildcards {
539 if let Some(suffix) = wildcard.strip_prefix("*.") {
540 ops.push(WriteOp::Delete(keys::wildcard(suffix)));
541 }
542 }
543 }
544
545 ops.push(WriteOp::Put(keys::site_config_blob(&hash), body));
547 ops.push(WriteOp::Put(
548 keys::site_pointer(project, site),
549 hash.into_bytes(),
550 ));
551
552 let owner_bytes = owner.to_bytes();
555 for host in config.domains.exact_hosts() {
556 ops.push(WriteOp::Put(keys::domain(host), owner_bytes.clone()));
557 }
558 for wildcard in &config.domains.wildcards {
559 if let Some(suffix) = wildcard.strip_prefix("*.") {
560 ops.push(WriteOp::Put(keys::wildcard(suffix), owner_bytes.clone()));
561 }
562 }
563 self.kv.write_batch(ops).await?;
564 Ok(())
565 }
566
567 async fn ensure_host_claimable(
573 &self,
574 key: &str,
575 label: &str,
576 owner: &DomainOwner,
577 ) -> Result<(), DeployError> {
578 if let Some(bytes) = self.kv.get(key).await? {
579 let held = DomainOwner::from_bytes(&bytes);
580 if &held != owner {
581 return Err(DeployError::Conflict(format!(
582 "{label} is already attached to site `{}` in project `{}`",
583 held.site, held.project
584 )));
585 }
586 }
587 Ok(())
588 }
589
590 pub async fn resolve_site_by_host(
595 &self,
596 host: &str,
597 ) -> Result<Option<DomainOwner>, DeployError> {
598 let host = canon_host(host);
601 let host = host.as_str();
602 if let Some(bytes) = self.kv.get(&keys::domain(host)).await? {
603 return Ok(Some(DomainOwner::from_bytes(&bytes)));
604 }
605 let mut rest = host;
606 while let Some((_, parent)) = rest.split_once('.') {
607 if let Some(bytes) = self.kv.get(&keys::wildcard(parent)).await? {
608 return Ok(Some(DomainOwner::from_bytes(&bytes)));
609 }
610 rest = parent;
611 }
612 Ok(None)
613 }
614
615 pub async fn all_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
621 let mut sites = BTreeSet::new();
622 for prefix in [
623 keys::current_prefix(project),
624 keys::site_prefix(project),
625 keys::history_prefix(project),
626 ] {
627 for key in self.kv.list_prefix(&prefix).await? {
628 if let Some(name) = key.strip_prefix(&prefix) {
629 if !name.is_empty() {
630 sites.insert(name.to_string());
631 }
632 }
633 }
634 }
635 Ok(sites.into_iter().collect())
636 }
637
638 pub async fn all_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
642 let mut out = Vec::new();
643 for project in self.discover_projects().await? {
644 for site in self.all_sites(ProjectRef::new(&project)).await? {
645 out.push((project.clone(), site));
646 }
647 }
648 Ok(out)
649 }
650
651 pub async fn discover_projects(&self) -> Result<Vec<String>, DeployError> {
656 let mut projects = BTreeSet::new();
657 for key in self.kv.list_prefix(keys::PROJECT_ROOT).await? {
658 if let Some(p) = keys::project_of_key(&key) {
659 projects.insert(p.to_string());
660 }
661 }
662 Ok(projects.into_iter().collect())
663 }
664
665 pub async fn put_function(
673 &self,
674 project: ProjectRef<'_>,
675 f: &crate::function::Function,
676 ) -> Result<(), DeployError> {
677 let bytes = serde_json::to_vec(f).map_err(|e| DeployError::Serde(e.to_string()))?;
678 self.kv
679 .put(
680 &crate::function::keys::meta(project.as_str(), &f.name),
681 bytes,
682 )
683 .await?;
684 Ok(())
685 }
686
687 pub async fn get_function(
689 &self,
690 project: ProjectRef<'_>,
691 name: &str,
692 ) -> Result<Option<crate::function::Function>, DeployError> {
693 match self
694 .kv
695 .get(&crate::function::keys::meta(project.as_str(), name))
696 .await?
697 {
698 Some(bytes) => Ok(Some(
699 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
700 )),
701 None => Ok(None),
702 }
703 }
704
705 pub async fn list_stored_functions(
711 &self,
712 project: ProjectRef<'_>,
713 ) -> Result<Vec<crate::function::Function>, DeployError> {
714 let prefix = crate::function::keys::functions_prefix(project.as_str());
715 let mut out = Vec::new();
716 for key in self.kv.list_prefix(&prefix).await? {
717 if key[prefix.len()..].contains('/') {
718 continue;
719 }
720 if let Some(bytes) = self.kv.get(&key).await? {
721 if let Ok(f) = serde_json::from_slice(&bytes) {
722 out.push(f);
723 }
724 }
725 }
726 Ok(out)
727 }
728
729 pub async fn delete_function(
732 &self,
733 project: ProjectRef<'_>,
734 name: &str,
735 ) -> Result<bool, DeployError> {
736 let key = crate::function::keys::meta(project.as_str(), name);
737 let existed = self.kv.get(&key).await?.is_some();
738 self.kv.delete(&key).await?;
739 Ok(existed)
740 }
741
742 pub async fn put_trigger(
746 &self,
747 project: ProjectRef<'_>,
748 function: &str,
749 trigger: &crate::function::FunctionTrigger,
750 ) -> Result<(), DeployError> {
751 let bytes = serde_json::to_vec(trigger).map_err(|e| DeployError::Serde(e.to_string()))?;
752 self.kv
753 .put(
754 &crate::function::keys::trigger(project.as_str(), function, &trigger.id),
755 bytes,
756 )
757 .await?;
758 Ok(())
759 }
760
761 pub async fn get_trigger(
763 &self,
764 project: ProjectRef<'_>,
765 function: &str,
766 id: &str,
767 ) -> Result<Option<crate::function::FunctionTrigger>, DeployError> {
768 match self
769 .kv
770 .get(&crate::function::keys::trigger(
771 project.as_str(),
772 function,
773 id,
774 ))
775 .await?
776 {
777 Some(bytes) => Ok(Some(
778 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
779 )),
780 None => Ok(None),
781 }
782 }
783
784 pub async fn list_triggers(
786 &self,
787 project: ProjectRef<'_>,
788 function: &str,
789 ) -> Result<Vec<crate::function::FunctionTrigger>, DeployError> {
790 let prefix = crate::function::keys::triggers_prefix(project.as_str(), function);
791 let mut out = Vec::new();
792 for key in self.kv.list_prefix(&prefix).await? {
793 if let Some(bytes) = self.kv.get(&key).await? {
794 if let Ok(t) = serde_json::from_slice(&bytes) {
795 out.push(t);
796 }
797 }
798 }
799 Ok(out)
800 }
801
802 pub async fn delete_trigger(
804 &self,
805 project: ProjectRef<'_>,
806 function: &str,
807 id: &str,
808 ) -> Result<bool, DeployError> {
809 let key = crate::function::keys::trigger(project.as_str(), function, id);
810 let existed = self.kv.get(&key).await?.is_some();
811 self.kv.delete(&key).await?;
812 Ok(existed)
813 }
814
815 pub async fn put_invocation(
819 &self,
820 project: ProjectRef<'_>,
821 inv: &crate::function::Invocation,
822 ) -> Result<(), DeployError> {
823 let bytes = serde_json::to_vec(inv).map_err(|e| DeployError::Serde(e.to_string()))?;
824 self.kv
825 .put(
826 &crate::function::keys::invocation(project.as_str(), &inv.function, &inv.id),
827 bytes,
828 )
829 .await?;
830 Ok(())
831 }
832
833 pub async fn get_invocation(
835 &self,
836 project: ProjectRef<'_>,
837 function: &str,
838 id: &str,
839 ) -> Result<Option<crate::function::Invocation>, DeployError> {
840 match self
841 .kv
842 .get(&crate::function::keys::invocation(
843 project.as_str(),
844 function,
845 id,
846 ))
847 .await?
848 {
849 Some(bytes) => Ok(Some(
850 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
851 )),
852 None => Ok(None),
853 }
854 }
855
856 pub async fn list_invocations(
858 &self,
859 project: ProjectRef<'_>,
860 function: &str,
861 ) -> Result<Vec<crate::function::Invocation>, DeployError> {
862 let prefix = crate::function::keys::invocations_prefix(project.as_str(), function);
863 let mut out = Vec::new();
864 for key in self.kv.list_prefix(&prefix).await? {
865 if let Some(bytes) = self.kv.get(&key).await? {
866 if let Ok(inv) = serde_json::from_slice(&bytes) {
867 out.push(inv);
868 }
869 }
870 }
871 Ok(out)
872 }
873
874 pub async fn put_idempotency(
877 &self,
878 project: ProjectRef<'_>,
879 function: &str,
880 key: &str,
881 invocation_id: &str,
882 ) -> Result<(), DeployError> {
883 self.kv
884 .put(
885 &crate::function::keys::idempotency(project.as_str(), function, key),
886 invocation_id.as_bytes().to_vec(),
887 )
888 .await?;
889 Ok(())
890 }
891
892 pub async fn get_idempotency(
894 &self,
895 project: ProjectRef<'_>,
896 function: &str,
897 key: &str,
898 ) -> Result<Option<String>, DeployError> {
899 match self
900 .kv
901 .get(&crate::function::keys::idempotency(
902 project.as_str(),
903 function,
904 key,
905 ))
906 .await?
907 {
908 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
909 None => Ok(None),
910 }
911 }
912
913 pub async fn get_metering(
917 &self,
918 project: ProjectRef<'_>,
919 function: &str,
920 ) -> Result<Option<crate::function::Metering>, DeployError> {
921 match self
922 .kv
923 .get(&crate::function::keys::metering(project.as_str(), function))
924 .await?
925 {
926 Some(bytes) => Ok(Some(
927 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
928 )),
929 None => Ok(None),
930 }
931 }
932
933 pub async fn put_metering(
935 &self,
936 project: ProjectRef<'_>,
937 metering: &crate::function::Metering,
938 ) -> Result<(), DeployError> {
939 let bytes = serde_json::to_vec(metering).map_err(|e| DeployError::Serde(e.to_string()))?;
940 self.kv
941 .put(
942 &crate::function::keys::metering(project.as_str(), &metering.function),
943 bytes,
944 )
945 .await?;
946 Ok(())
947 }
948
949 pub async fn list_metering(
952 &self,
953 project: ProjectRef<'_>,
954 ) -> Result<Vec<crate::function::Metering>, DeployError> {
955 let prefix = crate::function::keys::metering_prefix(project.as_str());
956 let mut out = Vec::new();
957 for key in self.kv.list_prefix(&prefix).await? {
958 if let Some(bytes) = self.kv.get(&key).await? {
959 if let Ok(m) = serde_json::from_slice(&bytes) {
960 out.push(m);
961 }
962 }
963 }
964 Ok(out)
965 }
966
967 pub async fn put_managed_notification(
972 &self,
973 project: ProjectRef<'_>,
974 record: &crate::blob_notify::ManagedNotification,
975 ) -> Result<(), DeployError> {
976 let bytes = record
977 .to_json()
978 .map_err(|e| DeployError::Serde(e.to_string()))?;
979 self.kv
980 .put(
981 &crate::blob_notify::blobnotify_key(
982 project.as_str(),
983 &record.function,
984 &record.prefix,
985 ),
986 bytes,
987 )
988 .await?;
989 Ok(())
990 }
991
992 pub async fn get_managed_notification(
994 &self,
995 project: ProjectRef<'_>,
996 function: &str,
997 prefix: &str,
998 ) -> Result<Option<crate::blob_notify::ManagedNotification>, DeployError> {
999 match self
1000 .kv
1001 .get(&crate::blob_notify::blobnotify_key(
1002 project.as_str(),
1003 function,
1004 prefix,
1005 ))
1006 .await?
1007 {
1008 Some(bytes) => Ok(Some(
1009 crate::blob_notify::ManagedNotification::from_json(&bytes)
1010 .map_err(|e| DeployError::Serde(e.to_string()))?,
1011 )),
1012 None => Ok(None),
1013 }
1014 }
1015
1016 pub async fn list_managed_notifications(
1018 &self,
1019 project: ProjectRef<'_>,
1020 function: &str,
1021 ) -> Result<Vec<crate::blob_notify::ManagedNotification>, DeployError> {
1022 let prefix = crate::blob_notify::blobnotify_function_prefix(project.as_str(), function);
1023 let mut out = Vec::new();
1024 for key in self.kv.list_prefix(&prefix).await? {
1025 if let Some(bytes) = self.kv.get(&key).await? {
1026 if let Ok(record) = crate::blob_notify::ManagedNotification::from_json(&bytes) {
1027 out.push(record);
1028 }
1029 }
1030 }
1031 Ok(out)
1032 }
1033
1034 pub async fn remove_managed_notification(
1036 &self,
1037 project: ProjectRef<'_>,
1038 function: &str,
1039 prefix: &str,
1040 ) -> Result<(), DeployError> {
1041 self.kv
1042 .delete(&crate::blob_notify::blobnotify_key(
1043 project.as_str(),
1044 function,
1045 prefix,
1046 ))
1047 .await?;
1048 Ok(())
1049 }
1050
1051 pub async fn put_workflow(
1055 &self,
1056 project: ProjectRef<'_>,
1057 workflow: &crate::workflow::Workflow,
1058 ) -> Result<(), DeployError> {
1059 let bytes = serde_json::to_vec(workflow).map_err(|e| DeployError::Serde(e.to_string()))?;
1060 self.kv
1061 .put(
1062 &crate::workflow::keys::definition(project.as_str(), &workflow.name),
1063 bytes,
1064 )
1065 .await?;
1066 Ok(())
1067 }
1068
1069 pub async fn get_workflow(
1071 &self,
1072 project: ProjectRef<'_>,
1073 name: &str,
1074 ) -> Result<Option<crate::workflow::Workflow>, DeployError> {
1075 match self
1076 .kv
1077 .get(&crate::workflow::keys::definition(project.as_str(), name))
1078 .await?
1079 {
1080 Some(bytes) => Ok(Some(
1081 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1082 )),
1083 None => Ok(None),
1084 }
1085 }
1086
1087 pub async fn list_workflows(
1091 &self,
1092 project: ProjectRef<'_>,
1093 ) -> Result<Vec<crate::workflow::Workflow>, DeployError> {
1094 let prefix = crate::workflow::keys::definitions_prefix(project.as_str());
1095 let mut out = Vec::new();
1096 for key in self.kv.list_prefix(&prefix).await? {
1097 if key[prefix.len()..].contains('/') {
1098 continue;
1099 }
1100 if let Some(bytes) = self.kv.get(&key).await? {
1101 if let Ok(w) = serde_json::from_slice(&bytes) {
1102 out.push(w);
1103 }
1104 }
1105 }
1106 Ok(out)
1107 }
1108
1109 pub async fn delete_workflow(
1112 &self,
1113 project: ProjectRef<'_>,
1114 name: &str,
1115 ) -> Result<bool, DeployError> {
1116 let key = crate::workflow::keys::definition(project.as_str(), name);
1117 let existed = self.kv.get(&key).await?.is_some();
1118 self.kv.delete(&key).await?;
1119 Ok(existed)
1120 }
1121
1122 pub async fn put_workflow_run(
1124 &self,
1125 project: ProjectRef<'_>,
1126 run: &crate::workflow::WorkflowRun,
1127 ) -> Result<(), DeployError> {
1128 let bytes = serde_json::to_vec(run).map_err(|e| DeployError::Serde(e.to_string()))?;
1129 self.kv
1130 .put(
1131 &crate::workflow::keys::run(project.as_str(), &run.workflow, &run.id),
1132 bytes,
1133 )
1134 .await?;
1135 Ok(())
1136 }
1137
1138 pub async fn get_workflow_run(
1140 &self,
1141 project: ProjectRef<'_>,
1142 workflow: &str,
1143 id: &str,
1144 ) -> Result<Option<crate::workflow::WorkflowRun>, DeployError> {
1145 match self
1146 .kv
1147 .get(&crate::workflow::keys::run(project.as_str(), workflow, id))
1148 .await?
1149 {
1150 Some(bytes) => Ok(Some(
1151 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1152 )),
1153 None => Ok(None),
1154 }
1155 }
1156
1157 pub async fn list_workflow_runs(
1159 &self,
1160 project: ProjectRef<'_>,
1161 workflow: &str,
1162 ) -> Result<Vec<crate::workflow::WorkflowRun>, DeployError> {
1163 let prefix = crate::workflow::keys::runs_prefix(project.as_str(), workflow);
1164 let mut out = Vec::new();
1165 for key in self.kv.list_prefix(&prefix).await? {
1166 if let Some(bytes) = self.kv.get(&key).await? {
1167 if let Ok(run) = serde_json::from_slice(&bytes) {
1168 out.push(run);
1169 }
1170 }
1171 }
1172 Ok(out)
1173 }
1174
1175 pub async fn get_domain_verification(
1177 &self,
1178 project: ProjectRef<'_>,
1179 site: &SiteName,
1180 host: &str,
1181 ) -> Result<Option<DomainVerification>, DeployError> {
1182 match self
1183 .kv
1184 .get(&keys::domain_verification(project, site.as_str(), host))
1185 .await?
1186 {
1187 Some(bytes) => Ok(Some(DomainVerification::from_json(&bytes)?)),
1188 None => Ok(None),
1189 }
1190 }
1191
1192 pub async fn list_domain_verifications(
1194 &self,
1195 project: ProjectRef<'_>,
1196 site: &SiteName,
1197 ) -> Result<Vec<DomainVerification>, DeployError> {
1198 let prefix = keys::domain_verification_prefix(project, site.as_str());
1199 let mut out = Vec::new();
1200 for key in self.kv.list_prefix(&prefix).await? {
1201 if let Some(bytes) = self.kv.get(&key).await? {
1202 out.push(DomainVerification::from_json(&bytes)?);
1203 }
1204 }
1205 out.sort_by(|a, b| a.host.cmp(&b.host));
1206 Ok(out)
1207 }
1208
1209 pub async fn list_all_domain_verifications(
1216 &self,
1217 ) -> Result<Vec<(String, String, DomainVerification)>, DeployError> {
1218 let mut out = Vec::new();
1219 for project in self.discover_projects().await? {
1220 let pref = ProjectRef::new(&project);
1221 let scan = keys::domain_verification_project_prefix(pref);
1222 for key in self.kv.list_prefix(&scan).await? {
1223 let Some((site, _host)) = key
1226 .strip_prefix(&scan)
1227 .and_then(|rest| rest.split_once('/'))
1228 else {
1229 continue;
1230 };
1231 if let Some(bytes) = self.kv.get(&key).await? {
1232 out.push((
1233 project.clone(),
1234 site.to_string(),
1235 DomainVerification::from_json(&bytes)?,
1236 ));
1237 }
1238 }
1239 }
1240 Ok(out)
1241 }
1242
1243 pub async fn find_pending_http_challenge(
1252 &self,
1253 host: &str,
1254 token: &str,
1255 now_unix: u64,
1256 ) -> Result<Option<DomainVerification>, DeployError> {
1257 let host = crate::domain_verify::normalize_host(host);
1258 let Some(owner_bytes) = self
1263 .kv
1264 .get(&keys::http_challenge_index(&host, token))
1265 .await?
1266 else {
1267 return Ok(None);
1268 };
1269 let owner = DomainOwner::from_bytes(&owner_bytes);
1270 let site = SiteName::new(owner.site);
1271 let Some(v) = self
1272 .get_domain_verification(ProjectRef::new(&owner.project), &site, &host)
1273 .await?
1274 else {
1275 return Ok(None);
1276 };
1277 if v.method == VerificationMethod::Http
1278 && v.host == host
1279 && v.matches(token)
1280 && !v.is_expired(now_unix)
1281 {
1282 Ok(Some(v))
1283 } else {
1284 Ok(None)
1285 }
1286 }
1287
1288 async fn put_domain_verification(
1289 &self,
1290 project: ProjectRef<'_>,
1291 site: &SiteName,
1292 verification: &DomainVerification,
1293 ) -> Result<(), DeployError> {
1294 let mut ops = vec![WriteOp::Put(
1295 keys::domain_verification(project, site.as_str(), &verification.host),
1296 verification.to_json()?,
1297 )];
1298 if verification.method == VerificationMethod::Http {
1304 ops.push(WriteOp::Put(
1305 keys::http_challenge_index(&verification.host, &verification.token),
1306 DomainOwner::new(project.as_str(), site.as_str()).to_bytes(),
1307 ));
1308 }
1309 self.kv.write_batch(ops).await?;
1310 Ok(())
1311 }
1312
1313 pub async fn get_managed_dns(
1317 &self,
1318 project: ProjectRef<'_>,
1319 site: &SiteName,
1320 host: &str,
1321 ) -> Result<Option<crate::dns_managed::ManagedDns>, DeployError> {
1322 match self
1323 .kv
1324 .get(&crate::dns_managed::dnsmanaged_key(
1325 project.as_str(),
1326 site.as_str(),
1327 host,
1328 ))
1329 .await?
1330 {
1331 Some(bytes) => Ok(Some(crate::dns_managed::ManagedDns::from_json(&bytes)?)),
1332 None => Ok(None),
1333 }
1334 }
1335
1336 pub async fn set_managed_dns(
1338 &self,
1339 project: ProjectRef<'_>,
1340 site: &SiteName,
1341 ledger: &crate::dns_managed::ManagedDns,
1342 ) -> Result<(), DeployError> {
1343 self.kv
1344 .put(
1345 &crate::dns_managed::dnsmanaged_key(project.as_str(), site.as_str(), &ledger.host),
1346 ledger.to_json()?,
1347 )
1348 .await?;
1349 Ok(())
1350 }
1351
1352 pub async fn remove_managed_dns(
1354 &self,
1355 project: ProjectRef<'_>,
1356 site: &SiteName,
1357 host: &str,
1358 ) -> Result<(), DeployError> {
1359 self.kv
1360 .delete(&crate::dns_managed::dnsmanaged_key(
1361 project.as_str(),
1362 site.as_str(),
1363 host,
1364 ))
1365 .await?;
1366 Ok(())
1367 }
1368
1369 pub async fn list_managed_dns(
1372 &self,
1373 project: ProjectRef<'_>,
1374 site: &SiteName,
1375 ) -> Result<Vec<crate::dns_managed::ManagedDns>, DeployError> {
1376 let prefix = crate::dns_managed::dnsmanaged_site_prefix(project.as_str(), site.as_str());
1377 let mut out = Vec::new();
1378 for key in self.kv.list_prefix(&prefix).await? {
1379 if let Some(bytes) = self.kv.get(&key).await? {
1380 out.push(crate::dns_managed::ManagedDns::from_json(&bytes)?);
1381 }
1382 }
1383 out.sort_by(|a, b| a.host.cmp(&b.host));
1384 Ok(out)
1385 }
1386
1387 pub async fn start_domain_verification(
1394 &self,
1395 project: ProjectRef<'_>,
1396 site: &SiteName,
1397 host: &str,
1398 method: VerificationMethod,
1399 now_unix: u64,
1400 ) -> Result<DomainVerification, DeployError> {
1401 if let Some(existing) = self.get_domain_verification(project, site, host).await? {
1402 if existing.verified || existing.method == method {
1403 return Ok(existing);
1404 }
1405 }
1406 const MAX_PENDING_VERIFICATIONS_PER_SITE: usize = 64;
1413 let pending = self
1414 .list_domain_verifications(project, site)
1415 .await?
1416 .into_iter()
1417 .filter(|v| !v.verified && !v.is_expired(now_unix))
1418 .count();
1419 if pending >= MAX_PENDING_VERIFICATIONS_PER_SITE {
1420 return Err(DeployError::Conflict(format!(
1421 "too many pending domain verifications for site {site} \
1422 (max {MAX_PENDING_VERIFICATIONS_PER_SITE}); verify or remove some first"
1423 )));
1424 }
1425 let verification = DomainVerification::new(host, method, now_unix);
1426 self.put_domain_verification(project, site, &verification)
1427 .await?;
1428 Ok(verification)
1429 }
1430
1431 pub async fn is_domain_verified(
1433 &self,
1434 project: ProjectRef<'_>,
1435 site: &SiteName,
1436 host: &str,
1437 ) -> Result<bool, DeployError> {
1438 Ok(self
1439 .get_domain_verification(project, site, host)
1440 .await?
1441 .is_some_and(|v| v.verified))
1442 }
1443
1444 pub async fn mark_domain_verified(
1447 &self,
1448 project: ProjectRef<'_>,
1449 site: &SiteName,
1450 host: &str,
1451 ) -> Result<DomainVerification, DeployError> {
1452 let mut verification = self
1453 .get_domain_verification(project, site, host)
1454 .await?
1455 .ok_or_else(|| {
1456 DeployError::NotFound(format!("no verification challenge for {host}"))
1457 })?;
1458 verification.verified = true;
1459 self.put_domain_verification(project, site, &verification)
1460 .await?;
1461 Ok(verification)
1462 }
1463
1464 pub async fn remove_domain_verification(
1467 &self,
1468 project: ProjectRef<'_>,
1469 site: &SiteName,
1470 host: &str,
1471 ) -> Result<bool, DeployError> {
1472 let Some(v) = self.get_domain_verification(project, site, host).await? else {
1473 return Ok(false);
1474 };
1475 let mut ops = vec![WriteOp::Delete(keys::domain_verification(
1476 project,
1477 site.as_str(),
1478 host,
1479 ))];
1480 if v.method == VerificationMethod::Http {
1481 ops.push(WriteOp::Delete(keys::http_challenge_index(
1482 &v.host, &v.token,
1483 )));
1484 }
1485 self.kv.write_batch(ops).await?;
1486 Ok(true)
1487 }
1488
1489 pub async fn attach_verified_domain(
1500 &self,
1501 project: ProjectRef<'_>,
1502 site: &SiteName,
1503 host: &str,
1504 ) -> Result<SiteConfig, DeployError> {
1505 if !self.is_domain_verified(project, site, host).await? {
1506 return Err(DeployError::NotFound(format!(
1507 "{host} is not verified for {site}; run domain verification first"
1508 )));
1509 }
1510 if host.trim_start().starts_with("*.")
1514 && self
1515 .get_domain_verification(project, site, host)
1516 .await?
1517 .map(|v| v.method)
1518 != Some(VerificationMethod::Dns)
1519 {
1520 return Err(DeployError::Conflict(format!(
1521 "wildcard {host} must be verified via DNS \
1522 (an HTTP token proves only the base host, not the subtree)"
1523 )));
1524 }
1525 let _claim = self.domain_claim_lock.lock().await;
1529 let mut config = self
1530 .get_site_config(project, site.as_str())
1531 .await?
1532 .unwrap_or_default();
1533 let domains = &mut config.domains;
1534 if let Some(suffix) = host.strip_prefix("*.") {
1535 let wildcard = format!("*.{}", suffix.trim_end_matches('.').to_ascii_lowercase());
1536 if !domains.wildcards.contains(&wildcard) {
1537 domains.wildcards.push(wildcard);
1538 }
1539 } else {
1540 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
1541 if domains.primary.is_none() {
1542 domains.primary = Some(host);
1543 } else if domains.primary.as_deref() != Some(host.as_str())
1544 && !domains.aliases.contains(&host)
1545 {
1546 domains.aliases.push(host);
1547 }
1548 }
1549 self.set_site_config_locked(project, site.as_str(), &config)
1550 .await?;
1551 Ok(config)
1552 }
1553
1554 pub async fn set_alias(
1560 &self,
1561 project: ProjectRef<'_>,
1562 site: &str,
1563 name: &str,
1564 id: &str,
1565 ) -> Result<(), DeployError> {
1566 let manifest = self
1567 .get_manifest(id)
1568 .await?
1569 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
1570 let missing = self.missing_blobs(&manifest).await?;
1571 if !missing.is_empty() {
1572 return Err(DeployError::Incomplete(missing));
1573 }
1574 self.kv
1575 .put(&keys::alias(project, site, name), id.as_bytes().to_vec())
1576 .await?;
1577 Ok(())
1578 }
1579
1580 pub async fn get_alias(
1582 &self,
1583 project: ProjectRef<'_>,
1584 site: &str,
1585 name: &str,
1586 ) -> Result<Option<String>, DeployError> {
1587 match self.kv.get(&keys::alias(project, site, name)).await? {
1588 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1589 None => Ok(None),
1590 }
1591 }
1592
1593 pub async fn remove_alias(
1595 &self,
1596 project: ProjectRef<'_>,
1597 site: &str,
1598 name: &str,
1599 ) -> Result<bool, DeployError> {
1600 let key = keys::alias(project, site, name);
1601 let existed = self.kv.get(&key).await?.is_some();
1602 if existed {
1603 self.kv.delete(&key).await?;
1604 }
1605 Ok(existed)
1606 }
1607
1608 pub async fn list_aliases(
1610 &self,
1611 project: ProjectRef<'_>,
1612 site: &str,
1613 ) -> Result<BTreeMap<String, String>, DeployError> {
1614 let prefix = keys::alias_prefix(project, site);
1615 let mut out = BTreeMap::new();
1616 for key in self.kv.list_prefix(&prefix).await? {
1617 if let Some(bytes) = self.kv.get(&key).await? {
1618 let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
1619 out.insert(name, String::from_utf8_lossy(&bytes).into_owned());
1620 }
1621 }
1622 Ok(out)
1623 }
1624
1625 pub async fn put_token_meta(&self, meta: &crate::authz::TokenMeta) -> Result<(), DeployError> {
1630 self.kv
1631 .put(
1632 &crate::authz::token_meta_key(&meta.revocation_id),
1633 serde_json::to_vec(meta)?,
1634 )
1635 .await?;
1636 Ok(())
1637 }
1638
1639 pub async fn list_token_meta(&self) -> Result<Vec<crate::authz::TokenMeta>, DeployError> {
1641 let mut out = Vec::new();
1642 for key in self.kv.list_prefix(crate::authz::TOKEN_META_PREFIX).await? {
1643 if let Some(bytes) = self.kv.get(&key).await? {
1644 if let Ok(meta) = serde_json::from_slice::<crate::authz::TokenMeta>(&bytes) {
1645 out.push(meta);
1646 }
1647 }
1648 }
1649 Ok(out)
1650 }
1651
1652 pub async fn revoke_token(&self, id_or_prefix: &str) -> Result<bool, DeployError> {
1657 let ids: Vec<String> = self
1658 .list_token_meta()
1659 .await?
1660 .into_iter()
1661 .map(|m| m.revocation_id)
1662 .collect();
1663 let matches: Vec<&String> = ids
1664 .iter()
1665 .filter(|id| id.starts_with(id_or_prefix))
1666 .collect();
1667 if let [id] = matches.as_slice() {
1668 self.kv
1669 .put(&crate::authz::revoked_key(id), Vec::new())
1670 .await?;
1671 self.kv.delete(&crate::authz::token_meta_key(id)).await?;
1672 Ok(true)
1673 } else {
1674 Ok(false)
1675 }
1676 }
1677
1678 pub async fn bootstrap_consumed(&self, secret_hash: &str) -> Result<bool, DeployError> {
1683 Ok(self
1684 .kv
1685 .get(&crate::authz::bootstrap_key(secret_hash))
1686 .await?
1687 .is_some())
1688 }
1689
1690 pub async fn mark_bootstrap_consumed(&self, secret_hash: &str) -> Result<(), DeployError> {
1692 self.kv
1693 .put(&crate::authz::bootstrap_key(secret_hash), Vec::new())
1694 .await?;
1695 Ok(())
1696 }
1697
1698 pub async fn get_authz_policy(&self) -> Result<Option<crate::authz::AuthzPolicy>, DeployError> {
1701 match self.kv.get(crate::authz::POLICY_KEY).await? {
1702 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1703 None => Ok(None),
1704 }
1705 }
1706
1707 pub async fn set_authz_policy(
1711 &self,
1712 policy: &crate::authz::AuthzPolicy,
1713 ) -> Result<(), DeployError> {
1714 self.kv
1715 .put(crate::authz::POLICY_KEY, serde_json::to_vec(policy)?)
1716 .await?;
1717 Ok(())
1718 }
1719
1720 pub async fn add_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1725 self.kv
1726 .put(&crate::authz::root_anchor_key(pubkey), Vec::new())
1727 .await?;
1728 Ok(())
1729 }
1730
1731 pub async fn remove_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1733 self.kv
1734 .delete(&crate::authz::root_anchor_key(pubkey))
1735 .await?;
1736 Ok(())
1737 }
1738
1739 pub async fn list_root_anchors(&self) -> Result<Vec<String>, DeployError> {
1741 Ok(self
1742 .kv
1743 .list_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1744 .await?
1745 .iter()
1746 .filter_map(|k| {
1747 k.strip_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1748 .map(String::from)
1749 })
1750 .collect())
1751 }
1752
1753 const DAEMON_CURRENT_KEY: &'static str = "daemon/current";
1757 const DAEMON_HISTORY_KEY: &'static str = "daemon/history";
1759 const DAEMON_HISTORY_MAX: usize = 20;
1761
1762 pub async fn daemon_config_generation(&self) -> Result<Option<String>, DeployError> {
1765 Ok(self
1766 .kv
1767 .get(Self::DAEMON_CURRENT_KEY)
1768 .await?
1769 .map(|b| String::from_utf8_lossy(&b).into_owned()))
1770 }
1771
1772 pub async fn get_daemon_config(
1775 &self,
1776 ) -> Result<Option<crate::daemon_config::DaemonConfig>, DeployError> {
1777 let Some(hash) = self.daemon_config_generation().await? else {
1778 return Ok(None);
1779 };
1780 match self.kv.get(&keys::daemon_config_blob(&hash)).await? {
1781 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1782 None => Ok(None),
1784 }
1785 }
1786
1787 pub async fn daemon_config_history(&self) -> Result<Vec<String>, DeployError> {
1790 match self.kv.get(Self::DAEMON_HISTORY_KEY).await? {
1791 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
1792 None => Ok(Vec::new()),
1793 }
1794 }
1795
1796 pub async fn set_daemon_config(
1802 &self,
1803 config: &crate::daemon_config::DaemonConfig,
1804 ) -> Result<String, DeployError> {
1805 let body = serde_json::to_vec(config)?;
1806 let hash = sha256_hex(&body);
1807 let mut history = self.daemon_config_history().await?;
1808 if let Some(current) = self.daemon_config_generation().await? {
1809 if current != hash {
1810 history.push(current);
1811 if history.len() > Self::DAEMON_HISTORY_MAX {
1812 let overflow = history.len() - Self::DAEMON_HISTORY_MAX;
1813 history.drain(0..overflow);
1814 }
1815 }
1816 }
1817 let ops = vec![
1818 WriteOp::Put(keys::daemon_config_blob(&hash), body),
1819 WriteOp::Put(
1820 Self::DAEMON_HISTORY_KEY.to_string(),
1821 serde_json::to_vec(&history)?,
1822 ),
1823 WriteOp::Put(
1824 Self::DAEMON_CURRENT_KEY.to_string(),
1825 hash.clone().into_bytes(),
1826 ),
1827 ];
1828 self.kv.write_batch(ops).await?;
1829 Ok(hash)
1830 }
1831
1832 pub async fn rollback_daemon_config(&self) -> Result<Option<String>, DeployError> {
1837 let mut history = self.daemon_config_history().await?;
1838 let Some(prev) = history.pop() else {
1839 return Ok(None);
1840 };
1841 let ops = vec![
1842 WriteOp::Put(
1843 Self::DAEMON_HISTORY_KEY.to_string(),
1844 serde_json::to_vec(&history)?,
1845 ),
1846 WriteOp::Put(
1847 Self::DAEMON_CURRENT_KEY.to_string(),
1848 prev.clone().into_bytes(),
1849 ),
1850 ];
1851 self.kv.write_batch(ops).await?;
1852 Ok(Some(prev))
1853 }
1854
1855 pub async fn put_compute_spec(
1860 &self,
1861 spec: &crate::compute::ComputeSpec,
1862 ) -> Result<String, DeployError> {
1863 let id = spec.id();
1864 self.kv
1865 .put(&crate::compute::spec_key(&id), serde_json::to_vec(spec)?)
1866 .await?;
1867 Ok(id)
1868 }
1869
1870 pub async fn get_compute_spec(
1872 &self,
1873 hash: &str,
1874 ) -> Result<Option<crate::compute::ComputeSpec>, DeployError> {
1875 match self.kv.get(&crate::compute::spec_key(hash)).await? {
1876 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1877 None => Ok(None),
1878 }
1879 }
1880
1881 pub async fn set_compute_workload(
1885 &self,
1886 project: ProjectRef<'_>,
1887 workload: &crate::compute::ComputeWorkload,
1888 ) -> Result<(), DeployError> {
1889 self.kv
1890 .put(
1891 &crate::compute::workload_key(project.as_str(), &workload.name),
1892 serde_json::to_vec(workload)?,
1893 )
1894 .await?;
1895 Ok(())
1896 }
1897
1898 pub async fn get_compute_workload(
1900 &self,
1901 project: ProjectRef<'_>,
1902 name: &str,
1903 ) -> Result<Option<crate::compute::ComputeWorkload>, DeployError> {
1904 match self
1905 .kv
1906 .get(&crate::compute::workload_key(project.as_str(), name))
1907 .await?
1908 {
1909 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1910 None => Ok(None),
1911 }
1912 }
1913
1914 pub async fn list_compute_workloads(
1916 &self,
1917 project: ProjectRef<'_>,
1918 ) -> Result<Vec<crate::compute::ComputeWorkload>, DeployError> {
1919 let prefix = crate::compute::workloads_prefix(project.as_str());
1920 let mut out = Vec::new();
1921 for key in self.kv.list_prefix(&prefix).await? {
1922 if let Some(bytes) = self.kv.get(&key).await? {
1923 if let Ok(w) = serde_json::from_slice::<crate::compute::ComputeWorkload>(&bytes) {
1924 out.push(w);
1925 }
1926 }
1927 }
1928 Ok(out)
1929 }
1930
1931 pub async fn list_compute_workloads_all(
1934 &self,
1935 ) -> Result<Vec<(String, crate::compute::ComputeWorkload)>, DeployError> {
1936 let mut out = Vec::new();
1937 for project in self.discover_projects().await? {
1938 for w in self
1939 .list_compute_workloads(ProjectRef::new(&project))
1940 .await?
1941 {
1942 out.push((project.clone(), w));
1943 }
1944 }
1945 Ok(out)
1946 }
1947
1948 pub async fn delete_compute_workload(
1951 &self,
1952 project: ProjectRef<'_>,
1953 name: &str,
1954 ) -> Result<bool, DeployError> {
1955 let key = crate::compute::workload_key(project.as_str(), name);
1956 let existed = self.kv.get(&key).await?.is_some();
1957 if existed {
1958 self.kv.delete(&key).await?;
1959 }
1960 Ok(existed)
1961 }
1962
1963 pub async fn set_replica_state(
1967 &self,
1968 project: ProjectRef<'_>,
1969 state: &crate::compute::ObservedInstance,
1970 ) -> Result<(), DeployError> {
1971 self.kv
1972 .put(
1973 &crate::compute::replica_state_key(
1974 project.as_str(),
1975 &state.handle.workload,
1976 state.handle.replica,
1977 ),
1978 serde_json::to_vec(state)?,
1979 )
1980 .await?;
1981 Ok(())
1982 }
1983
1984 pub async fn list_replica_states(
1986 &self,
1987 project: ProjectRef<'_>,
1988 workload: &str,
1989 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
1990 let mut out = Vec::new();
1991 for key in self
1992 .kv
1993 .list_prefix(&crate::compute::replica_state_prefix(
1994 project.as_str(),
1995 workload,
1996 ))
1997 .await?
1998 {
1999 if let Some(bytes) = self.kv.get(&key).await? {
2000 if let Ok(state) =
2001 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2002 {
2003 out.push(state);
2004 }
2005 }
2006 }
2007 Ok(out)
2008 }
2009
2010 pub async fn list_all_replica_states(
2014 &self,
2015 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2016 let mut out = Vec::new();
2017 for project in self.discover_projects().await? {
2018 let prefix = crate::compute::replica_states_project_prefix(&project);
2019 for key in self.kv.list_prefix(&prefix).await? {
2020 if let Some(bytes) = self.kv.get(&key).await? {
2021 if let Ok(state) =
2022 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2023 {
2024 out.push(state);
2025 }
2026 }
2027 }
2028 }
2029 Ok(out)
2030 }
2031
2032 pub async fn delete_replica_state(
2034 &self,
2035 project: ProjectRef<'_>,
2036 workload: &str,
2037 replica: u32,
2038 ) -> Result<(), DeployError> {
2039 self.kv
2040 .delete(&crate::compute::replica_state_key(
2041 project.as_str(),
2042 workload,
2043 replica,
2044 ))
2045 .await?;
2046 Ok(())
2047 }
2048
2049 pub async fn put_project(&self, p: &crate::project::Project) -> Result<String, DeployError> {
2061 let hash = p.id();
2062 let body = serde_json::to_vec(p).map_err(|e| DeployError::Serde(e.to_string()))?;
2063 let pointer = crate::project::pointer_key(&p.name);
2064 let mut history: Vec<String> =
2067 match self.kv.get(&crate::project::history_key(&p.name)).await? {
2068 Some(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
2069 None => Vec::new(),
2070 };
2071 if let Some(current) = self.kv.get(&pointer).await? {
2072 let current = String::from_utf8_lossy(¤t).into_owned();
2073 if current != hash {
2074 history.retain(|h| h != ¤t);
2075 history.insert(0, current);
2076 history.truncate(MAX_HISTORY);
2077 }
2078 }
2079 self.kv
2080 .write_batch(vec![
2081 WriteOp::Put(crate::project::spec_key(&hash), body),
2082 WriteOp::Put(pointer, hash.clone().into_bytes()),
2083 WriteOp::Put(
2084 crate::project::history_key(&p.name),
2085 serde_json::to_vec(&history).map_err(|e| DeployError::Serde(e.to_string()))?,
2086 ),
2087 ])
2088 .await?;
2089 Ok(hash)
2090 }
2091
2092 pub fn default_project_record() -> crate::project::Project {
2097 crate::project::Project {
2098 version: crate::SCHEMA_VERSION,
2099 name: crate::project::DEFAULT_PROJECT.to_string(),
2100 created_at: now_unix(),
2101 meta: crate::project::ProjectMeta::default(),
2102 config: crate::project::ProjectConfig::default(),
2103 secrets_ref: None,
2104 }
2105 }
2106
2107 pub async fn ensure_default_project(&self) -> Result<bool, DeployError> {
2115 let pointer = crate::project::pointer_key(crate::project::DEFAULT_PROJECT);
2116 if self.kv.get(&pointer).await?.is_some() {
2117 return Ok(false);
2118 }
2119 let default = Self::default_project_record();
2120 let hash = default.id();
2121 let body = serde_json::to_vec(&default).map_err(|e| DeployError::Serde(e.to_string()))?;
2122 self.kv
2123 .write_batch(vec![
2124 WriteOp::Put(crate::project::spec_key(&hash), body),
2125 WriteOp::Put(pointer, hash.into_bytes()),
2126 ])
2127 .await?;
2128 Ok(true)
2129 }
2130
2131 pub async fn project_exists(&self, name: &str) -> Result<bool, DeployError> {
2136 if name == crate::project::DEFAULT_PROJECT {
2137 return Ok(true);
2138 }
2139 Ok(self
2140 .kv
2141 .get(&crate::project::pointer_key(name))
2142 .await?
2143 .is_some())
2144 }
2145
2146 pub async fn get_project(
2148 &self,
2149 name: &str,
2150 ) -> Result<Option<crate::project::Project>, DeployError> {
2151 let Some(hash) = self.kv.get(&crate::project::pointer_key(name)).await? else {
2152 if name == crate::project::DEFAULT_PROJECT {
2156 return Ok(Some(Self::default_project_record()));
2157 }
2158 return Ok(None);
2159 };
2160 let hash = String::from_utf8_lossy(&hash).into_owned();
2161 match self.kv.get(&crate::project::spec_key(&hash)).await? {
2162 Some(bytes) => Ok(Some(
2163 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
2164 )),
2165 None if name == crate::project::DEFAULT_PROJECT => {
2168 Ok(Some(Self::default_project_record()))
2169 }
2170 None => Ok(None),
2171 }
2172 }
2173
2174 pub async fn list_projects(&self) -> Result<Vec<crate::project::Project>, DeployError> {
2178 let mut out = Vec::new();
2179 for key in self.kv.list_prefix(crate::project::POINTER_PREFIX).await? {
2180 let Some(name) = key.strip_prefix(crate::project::POINTER_PREFIX) else {
2181 continue;
2182 };
2183 if !name.is_empty() {
2184 if let Some(p) = self.get_project(name).await? {
2185 out.push(p);
2186 }
2187 }
2188 }
2189 if !out
2192 .iter()
2193 .any(|p| p.name == crate::project::DEFAULT_PROJECT)
2194 {
2195 out.push(Self::default_project_record());
2196 }
2197 out.sort_by(|a, b| a.name.cmp(&b.name));
2198 Ok(out)
2199 }
2200
2201 pub async fn delete_project(&self, name: &str) -> Result<bool, DeployError> {
2207 if name == crate::project::DEFAULT_PROJECT {
2208 return Err(DeployError::Conflict(
2209 "the `default` project cannot be deleted".to_string(),
2210 ));
2211 }
2212 let existed = self
2213 .kv
2214 .get(&crate::project::pointer_key(name))
2215 .await?
2216 .is_some();
2217 if !self
2219 .kv
2220 .list_prefix(&crate::project::resource_prefix(name))
2221 .await?
2222 .is_empty()
2223 {
2224 return Err(DeployError::Conflict(format!(
2225 "project `{name}` still owns resources; delete its sites/functions/compute first"
2226 )));
2227 }
2228 self.kv
2229 .write_batch(vec![
2230 WriteOp::Delete(crate::project::pointer_key(name)),
2231 WriteOp::Delete(crate::project::history_key(name)),
2232 ])
2233 .await?;
2234 Ok(existed)
2235 }
2236
2237 pub async fn activate(
2241 &self,
2242 project: ProjectRef<'_>,
2243 site: &str,
2244 id: &str,
2245 ) -> Result<(), DeployError> {
2246 let manifest = self
2247 .get_manifest(id)
2248 .await?
2249 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
2250 let missing = self.missing_blobs(&manifest).await?;
2251 if !missing.is_empty() {
2252 return Err(DeployError::Incomplete(missing));
2253 }
2254
2255 self.kv
2258 .put(&keys::current(project, site), id.as_bytes().to_vec())
2259 .await?;
2260
2261 let _ = self.record_history(project, site, id).await;
2265 Ok(())
2266 }
2267
2268 async fn record_history(
2271 &self,
2272 project: ProjectRef<'_>,
2273 site: &str,
2274 id: &str,
2275 ) -> Result<(), DeployError> {
2276 let mut history = self.history(project, site).await.unwrap_or_default();
2277 history.retain(|entry| entry.id != id);
2278 history.insert(
2279 0,
2280 HistoryEntry {
2281 id: id.to_string(),
2282 at: now_unix(),
2283 meta: None,
2284 },
2285 );
2286 history.truncate(MAX_HISTORY);
2287 self.kv
2288 .put(&keys::history(project, site), serde_json::to_vec(&history)?)
2289 .await?;
2290 Ok(())
2291 }
2292
2293 pub async fn history(
2295 &self,
2296 project: ProjectRef<'_>,
2297 site: &str,
2298 ) -> Result<Vec<HistoryEntry>, DeployError> {
2299 match self.kv.get(&keys::history(project, site)).await? {
2300 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2301 None => Ok(Vec::new()),
2302 }
2303 }
2304
2305 pub async fn deployments(
2308 &self,
2309 project: ProjectRef<'_>,
2310 site: &str,
2311 ) -> Result<DeploymentList, DeployError> {
2312 let mut deployments = self.history(project, site).await?;
2313 for entry in &mut deployments {
2314 entry.meta = self.get_meta(&entry.id).await?;
2315 }
2316 Ok(DeploymentList {
2317 current: self.current_id(project, site).await?,
2318 deployments,
2319 })
2320 }
2321
2322 async fn live_deployment_ids(&self, opts: &GcOptions) -> Result<BTreeSet<String>, DeployError> {
2333 let mut ids = BTreeSet::new();
2334 let now = now_unix();
2335 for project in self.discover_projects().await? {
2336 let pref = ProjectRef::new(&project);
2337 for key in self.kv.list_prefix(&keys::history_prefix(pref)).await? {
2338 if let Some(bytes) = self.kv.get(&key).await? {
2339 if let Ok(history) = serde_json::from_slice::<Vec<HistoryEntry>>(&bytes) {
2340 for (idx, entry) in history.iter().enumerate() {
2341 let within_count = opts.keep_last.is_none_or(|n| idx < n);
2342 let within_age = opts
2343 .keep_age_secs
2344 .is_some_and(|age| now.saturating_sub(entry.at) <= age);
2345 if within_count || within_age {
2346 ids.insert(entry.id.clone());
2347 }
2348 }
2349 }
2350 }
2351 }
2352 for prefix in [keys::current_prefix(pref), keys::alias_project_prefix(pref)] {
2354 for key in self.kv.list_prefix(&prefix).await? {
2355 if let Some(bytes) = self.kv.get(&key).await? {
2356 ids.insert(String::from_utf8_lossy(&bytes).into_owned());
2357 }
2358 }
2359 }
2360 }
2361 Ok(ids)
2362 }
2363
2364 async fn within_grace(
2369 &self,
2370 id: &str,
2371 now: u64,
2372 opts: &GcOptions,
2373 ) -> Result<bool, DeployError> {
2374 if opts.grace_secs == 0 {
2375 return Ok(false);
2376 }
2377 match self.get_meta(id).await? {
2378 Some(meta) => Ok(now.saturating_sub(meta.created_at) < opts.grace_secs),
2379 None => Ok(false),
2380 }
2381 }
2382
2383 pub async fn collect_garbage(&self, prune: bool) -> Result<GcReport, DeployError> {
2390 self.collect_garbage_with(prune, GcOptions::default()).await
2391 }
2392
2393 pub async fn collect_garbage_with(
2404 &self,
2405 prune: bool,
2406 opts: GcOptions,
2407 ) -> Result<GcReport, DeployError> {
2408 let live_ids = self.live_deployment_ids(&opts).await?;
2409 let now = now_unix();
2410
2411 let manifest_keys = self.kv.list_prefix("manifests/").await?;
2412 let manifests_total = manifest_keys.len();
2413 let mut referenced: BTreeSet<String> = BTreeSet::new();
2414 let mut orphan_manifests: Vec<String> = Vec::new();
2415 for key in &manifest_keys {
2416 let id = key.strip_prefix("manifests/").unwrap_or(key);
2417 let protected = live_ids.contains(id) || self.within_grace(id, now, &opts).await?;
2418 if protected {
2419 if let Some(bytes) = self.kv.get(key).await? {
2420 if let Ok(manifest) = Manifest::from_bytes(&bytes) {
2421 referenced.extend(manifest.blob_hashes());
2422 }
2423 }
2424 } else {
2425 orphan_manifests.push(key.clone());
2426 }
2427 }
2428
2429 let blobs = self.storage.list("").await?;
2430 let blobs_total = blobs.len();
2431 let mut blobs_removed = 0;
2432 let mut bytes_reclaimed = 0;
2433 for meta in &blobs {
2434 if !is_blob_key(&meta.key) {
2435 continue;
2436 }
2437 let hash = meta.key.rsplit('/').next().unwrap_or(&meta.key);
2438 if !referenced.contains(hash) {
2439 blobs_removed += 1;
2440 bytes_reclaimed += meta.size.unwrap_or(0);
2441 if prune {
2442 self.storage.delete(&meta.key).await?;
2443 }
2444 }
2445 }
2446
2447 if prune {
2453 let mut referenced_configs: BTreeSet<String> = BTreeSet::new();
2457 for project in self.discover_projects().await? {
2458 let site_prefix = keys::site_prefix(ProjectRef::new(&project));
2459 for pointer in self.kv.list_prefix(&site_prefix).await? {
2460 if let Some(bytes) = self.kv.get(&pointer).await? {
2461 referenced_configs.insert(String::from_utf8_lossy(&bytes).into_owned());
2462 }
2463 }
2464 }
2465 for key in self.kv.list_prefix("siteconfig/").await? {
2466 let hash = key.strip_prefix("siteconfig/").unwrap_or(&key);
2467 if !referenced_configs.contains(hash) {
2468 self.kv.delete(&key).await?;
2469 }
2470 }
2471 }
2472
2473 let manifests_removed = orphan_manifests.len();
2474 if prune {
2475 for key in &orphan_manifests {
2476 self.kv.delete(key).await?;
2477 if let Some(id) = key.strip_prefix("manifests/") {
2479 let _ = self.kv.delete(&keys::meta(id)).await;
2480 }
2481 }
2482 }
2483
2484 Ok(GcReport {
2485 manifests_total,
2486 manifests_removed,
2487 blobs_total,
2488 blobs_removed,
2489 bytes_reclaimed,
2490 })
2491 }
2492
2493 pub async fn scrub_blobs(&self) -> Result<ScrubReport, DeployError> {
2499 let blobs = self.storage.list("").await?;
2500 let mut report = ScrubReport::default();
2501 for meta in &blobs {
2502 if !is_blob_key(&meta.key) {
2503 continue;
2504 }
2505 report.checked += 1;
2506 let expected = meta
2507 .key
2508 .rsplit('/')
2509 .next()
2510 .unwrap_or(meta.key.as_str())
2511 .to_string();
2512 match self.hash_stored_object(&meta.key).await {
2513 Ok(actual) if actual == expected => {}
2514 Ok(actual) => report.mismatched.push(BlobMismatch {
2515 key: meta.key.clone(),
2516 expected,
2517 actual,
2518 }),
2519 Err(err) => report.errors.push(BlobReadError {
2520 key: meta.key.clone(),
2521 error: err.to_string(),
2522 }),
2523 }
2524 }
2525 Ok(report)
2526 }
2527
2528 pub fn invalidate_cache_keys(&self, keys: &[String]) {
2534 self.kv.invalidate_keys(keys);
2535 }
2536
2537 pub fn invalidate_cache(&self) {
2540 self.kv.invalidate_cache();
2541 }
2542
2543 pub async fn cert_status(&self) -> Result<Vec<crate::cert::CertStatus>, DeployError> {
2548 let mut out = Vec::new();
2549 for key in self.kv.list_prefix("cert/").await? {
2550 let domain = key.strip_prefix("cert/").unwrap_or(&key).to_string();
2551 if let Some(bytes) = self.kv.get(&key).await? {
2552 if let Ok(cert) = serde_json::from_slice::<crate::cert::StoredCert>(&bytes) {
2553 out.push(crate::cert::CertStatus {
2554 domain,
2555 not_after_unix: cert.not_after_unix,
2556 });
2557 }
2558 }
2559 }
2560 out.sort_by(|a, b| a.domain.cmp(&b.domain));
2561 Ok(out)
2562 }
2563
2564 async fn hash_stored_object(&self, key: &str) -> Result<String, DeployError> {
2566 let mut body = self.storage.get(key).await?.body;
2567 let mut hasher = Sha256::new();
2568 while let Some(chunk) = body.next().await {
2569 hasher.update(&chunk?);
2570 }
2571 Ok(hex::encode(hasher.finalize()))
2572 }
2573
2574 pub async fn list_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
2578 let prefix = keys::current_prefix(project);
2579 let keys = self.kv.list_prefix(&prefix).await?;
2580 Ok(keys
2581 .into_iter()
2582 .filter_map(|k| k.strip_prefix(&prefix).map(str::to_string))
2583 .collect())
2584 }
2585
2586 pub async fn list_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
2589 let mut out = Vec::new();
2590 for project in self.discover_projects().await? {
2591 for site in self.list_sites(ProjectRef::new(&project)).await? {
2592 out.push((project.clone(), site));
2593 }
2594 }
2595 Ok(out)
2596 }
2597
2598 pub async fn delete_site(
2605 &self,
2606 project: ProjectRef<'_>,
2607 site: &str,
2608 ) -> Result<(), DeployError> {
2609 use crate::kv::WriteOp;
2610 let _claim = self.domain_claim_lock.lock().await;
2613 let mut batch = vec![
2614 WriteOp::Delete(keys::site_pointer(project, site)),
2615 WriteOp::Delete(keys::current(project, site)),
2616 WriteOp::Delete(keys::history(project, site)),
2617 ];
2618 if let Some(config) = self.get_site_config(project, site).await? {
2619 for host in config.domains.exact_hosts() {
2620 batch.push(WriteOp::Delete(keys::domain(host)));
2621 }
2622 for wildcard in &config.domains.wildcards {
2623 if let Some(suffix) = wildcard.strip_prefix("*.") {
2624 batch.push(WriteOp::Delete(keys::wildcard(suffix)));
2625 }
2626 }
2627 }
2628 for key in self
2629 .kv
2630 .list_prefix(&keys::alias_prefix(project, site))
2631 .await?
2632 {
2633 batch.push(WriteOp::Delete(key));
2634 }
2635 for key in self
2636 .kv
2637 .list_prefix(&keys::domain_verification_prefix(project, site))
2638 .await?
2639 {
2640 batch.push(WriteOp::Delete(key));
2641 }
2642 self.kv.write_batch(batch).await?;
2643 Ok(())
2644 }
2645
2646 pub async fn current_id(
2648 &self,
2649 project: ProjectRef<'_>,
2650 site: &str,
2651 ) -> Result<Option<String>, DeployError> {
2652 match self.kv.get(&keys::current(project, site)).await? {
2653 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
2654 None => Ok(None),
2655 }
2656 }
2657
2658 pub async fn current_manifest(
2660 &self,
2661 project: ProjectRef<'_>,
2662 site: &str,
2663 ) -> Result<Option<Manifest>, DeployError> {
2664 match self.current_id(project, site).await? {
2665 Some(id) => self.get_manifest(&id).await,
2666 None => Ok(None),
2667 }
2668 }
2669
2670 pub async fn resolve(
2675 &self,
2676 project: ProjectRef<'_>,
2677 site: &str,
2678 path: &str,
2679 ) -> Result<Option<FileEntry>, DeployError> {
2680 let Some(manifest) = self.current_manifest(project, site).await? else {
2681 return Ok(None);
2682 };
2683 Ok(lookup(&manifest, path))
2684 }
2685}
2686
2687fn lookup(manifest: &Manifest, path: &str) -> Option<FileEntry> {
2689 let trimmed = path.trim_start_matches('/');
2690 if let Some(entry) = manifest.files.get(trimmed) {
2691 return Some(entry.clone());
2692 }
2693 let index = if trimmed.is_empty() {
2694 "index.html".to_string()
2695 } else {
2696 format!("{}/index.html", trimmed.trim_end_matches('/'))
2697 };
2698 manifest.files.get(&index).cloned()
2699}
2700
2701#[cfg(test)]
2702mod tests {
2703 use super::*;
2704 use crate::config::DeployConfig;
2705 use crate::ObjectMeta;
2706
2707 fn entry(hash: &str) -> FileEntry {
2708 FileEntry {
2709 hash: hash.to_string(),
2710 size: 0,
2711 content_type: None,
2712 variants: BTreeMap::new(),
2713 }
2714 }
2715
2716 #[test]
2717 fn manifest_id_is_deterministic() {
2718 let mut a = Manifest::default();
2719 a.files.insert("index.html".into(), entry("aa"));
2720 a.files.insert("style.css".into(), entry("bb"));
2721
2722 let mut b = Manifest::default();
2723 b.files.insert("style.css".into(), entry("bb"));
2725 b.files.insert("index.html".into(), entry("aa"));
2726
2727 assert_eq!(a.id().unwrap(), b.id().unwrap());
2728 }
2729
2730 #[test]
2731 fn manifest_carries_schema_version_and_reads_legacy() {
2732 let manifest = Manifest::default();
2734 assert_eq!(manifest.version, crate::SCHEMA_VERSION);
2735 assert!(manifest.to_bytes().unwrap().starts_with(b"{\"version\":1"));
2736
2737 let legacy = br#"{"files":{},"config":{}}"#;
2739 assert_eq!(Manifest::from_bytes(legacy).unwrap().version, 1);
2740 }
2741
2742 #[test]
2743 fn directory_index_fallback() {
2744 let mut m = Manifest::default();
2745 m.files.insert("index.html".into(), entry("root"));
2746 m.files.insert("blog/index.html".into(), entry("blog"));
2747
2748 assert_eq!(lookup(&m, "").unwrap().hash, "root");
2749 assert_eq!(lookup(&m, "/").unwrap().hash, "root");
2750 assert_eq!(lookup(&m, "blog").unwrap().hash, "blog");
2751 assert_eq!(lookup(&m, "blog/").unwrap().hash, "blog");
2752 assert!(lookup(&m, "missing.html").is_none());
2753 }
2754
2755 struct NullStorage;
2758
2759 #[async_trait::async_trait]
2760 impl Storage for NullStorage {
2761 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
2762 Err(StorageError::NotFound(String::new()))
2763 }
2764 async fn get_range(
2765 &self,
2766 _: &str,
2767 _: u64,
2768 _: Option<u64>,
2769 ) -> Result<GetObject, StorageError> {
2770 Err(StorageError::NotFound(String::new()))
2771 }
2772 async fn put(
2773 &self,
2774 _: &str,
2775 _: ByteStream,
2776 _: PutMeta,
2777 ) -> Result<ObjectMeta, StorageError> {
2778 Err(StorageError::unsupported("null"))
2779 }
2780 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
2781 Err(StorageError::NotFound(String::new()))
2782 }
2783 async fn delete(&self, _: &str) -> Result<(), StorageError> {
2784 Ok(())
2785 }
2786 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2787 Ok(Vec::new())
2788 }
2789 }
2790
2791 #[derive(Default)]
2793 struct MemStorage {
2794 objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
2795 }
2796
2797 #[async_trait::async_trait]
2798 impl Storage for MemStorage {
2799 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2800 let bytes = self
2801 .objects
2802 .lock()
2803 .unwrap()
2804 .get(key)
2805 .cloned()
2806 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2807 let size = bytes.len() as u64;
2808 let body: ByteStream =
2809 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2810 Ok(GetObject {
2811 meta: ObjectMeta {
2812 key: key.to_string(),
2813 size: Some(size),
2814 ..Default::default()
2815 },
2816 body,
2817 })
2818 }
2819 async fn get_range(
2820 &self,
2821 key: &str,
2822 _: u64,
2823 _: Option<u64>,
2824 ) -> Result<GetObject, StorageError> {
2825 self.get(key).await
2826 }
2827 async fn put(
2828 &self,
2829 key: &str,
2830 mut body: ByteStream,
2831 _: PutMeta,
2832 ) -> Result<ObjectMeta, StorageError> {
2833 let mut buf = Vec::new();
2834 while let Some(chunk) = body.next().await {
2835 buf.extend_from_slice(&chunk?);
2836 }
2837 let size = buf.len() as u64;
2838 self.objects.lock().unwrap().insert(key.to_string(), buf);
2839 Ok(ObjectMeta {
2840 key: key.to_string(),
2841 size: Some(size),
2842 ..Default::default()
2843 })
2844 }
2845 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2846 let map = self.objects.lock().unwrap();
2847 let bytes = map
2848 .get(key)
2849 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2850 Ok(ObjectMeta {
2851 key: key.to_string(),
2852 size: Some(bytes.len() as u64),
2853 ..Default::default()
2854 })
2855 }
2856 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2857 self.objects.lock().unwrap().remove(key);
2858 Ok(())
2859 }
2860 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2861 Ok(self
2862 .objects
2863 .lock()
2864 .unwrap()
2865 .keys()
2866 .filter(|k| k.starts_with(prefix))
2867 .map(|k| ObjectMeta {
2868 key: k.clone(),
2869 ..Default::default()
2870 })
2871 .collect())
2872 }
2873 }
2874
2875 fn once_bytes(b: &'static [u8]) -> ByteStream {
2877 futures::stream::once(async move { Ok(bytes::Bytes::from_static(b)) }).boxed()
2878 }
2879
2880 fn manifest_with(files: &[(&str, &str)]) -> Manifest {
2882 let mut m = Manifest::default();
2883 for (path, hash) in files {
2884 m.files.insert(
2885 (*path).to_string(),
2886 FileEntry {
2887 hash: (*hash).to_string(),
2888 size: 1,
2889 content_type: None,
2890 variants: Default::default(),
2891 },
2892 );
2893 }
2894 m
2895 }
2896
2897 #[tokio::test]
2898 async fn function_storage_versioning_alias_rollback() {
2899 use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
2900 use crate::kv::MemoryKv;
2901
2902 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
2903 assert!(store
2904 .list_stored_functions(ProjectRef::DEFAULT)
2905 .await
2906 .unwrap()
2907 .is_empty());
2908
2909 let mut f = Function::new(
2910 "resize",
2911 Owner::Project("acme".into()),
2912 "hashA",
2913 FunctionConfig::default(),
2914 Lifecycle::Independent,
2915 1,
2916 );
2917 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2918 assert_eq!(
2919 store
2920 .get_function(ProjectRef::DEFAULT, "resize")
2921 .await
2922 .unwrap()
2923 .unwrap()
2924 .active,
2925 "hashA"
2926 );
2927 assert_eq!(
2928 store
2929 .list_stored_functions(ProjectRef::DEFAULT)
2930 .await
2931 .unwrap()
2932 .len(),
2933 1
2934 );
2935
2936 f.upsert_version("hashB", Lifecycle::Independent, 2);
2938 f.set_alias("prod", "hashA").unwrap();
2939 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2940 let got = store
2941 .get_function(ProjectRef::DEFAULT, "resize")
2942 .await
2943 .unwrap()
2944 .unwrap();
2945 assert_eq!(got.active, "hashB");
2946 assert_eq!(got.aliases.get("prod").map(String::as_str), Some("hashA"));
2947
2948 assert!(store
2950 .delete_function(ProjectRef::DEFAULT, "resize")
2951 .await
2952 .unwrap());
2953 assert!(store
2954 .get_function(ProjectRef::DEFAULT, "resize")
2955 .await
2956 .unwrap()
2957 .is_none());
2958 assert!(!store
2959 .delete_function(ProjectRef::DEFAULT, "resize")
2960 .await
2961 .unwrap());
2962 }
2963
2964 #[tokio::test]
2965 async fn function_invocation_and_idempotency_storage() {
2966 use crate::function::{
2967 Function, FunctionConfig, Invocation, InvocationResult, InvocationStatus, InvokeMode,
2968 Lifecycle, Owner,
2969 };
2970 use crate::kv::MemoryKv;
2971
2972 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
2973 let f = Function::new(
2975 "greeter",
2976 Owner::Project("acme".into()),
2977 "hashA",
2978 FunctionConfig::default(),
2979 Lifecycle::Independent,
2980 1,
2981 );
2982 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
2983
2984 let mut inv = Invocation {
2985 id: "inv-1".into(),
2986 function: "greeter".into(),
2987 version: "hashA".into(),
2988 mode: InvokeMode::Async,
2989 status: InvocationStatus::Queued,
2990 idempotency_key: Some("key-1".into()),
2991 attempts: 0,
2992 request_b64: None,
2993 request_content_type: None,
2994 result: None,
2995 created: 10,
2996 updated: 10,
2997 };
2998 store
2999 .put_invocation(ProjectRef::DEFAULT, &inv)
3000 .await
3001 .unwrap();
3002 store
3003 .put_idempotency(ProjectRef::DEFAULT, "greeter", "key-1", "inv-1")
3004 .await
3005 .unwrap();
3006
3007 assert_eq!(
3009 store
3010 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3011 .await
3012 .unwrap()
3013 .unwrap()
3014 .status,
3015 InvocationStatus::Queued
3016 );
3017 assert_eq!(
3018 store
3019 .get_idempotency(ProjectRef::DEFAULT, "greeter", "key-1")
3020 .await
3021 .unwrap(),
3022 Some("inv-1".to_string())
3023 );
3024 assert_eq!(
3025 store
3026 .list_invocations(ProjectRef::DEFAULT, "greeter")
3027 .await
3028 .unwrap()
3029 .len(),
3030 1
3031 );
3032 assert_eq!(
3034 store
3035 .list_stored_functions(ProjectRef::DEFAULT)
3036 .await
3037 .unwrap()
3038 .len(),
3039 1
3040 );
3041
3042 inv.status = InvocationStatus::Succeeded;
3044 inv.attempts = 1;
3045 inv.result = Some(InvocationResult {
3046 status: 200,
3047 content_type: Some("text/plain".into()),
3048 body_b64: "aGVsbG8=".into(),
3049 });
3050 inv.updated = 20;
3051 store
3052 .put_invocation(ProjectRef::DEFAULT, &inv)
3053 .await
3054 .unwrap();
3055 let got = store
3056 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3057 .await
3058 .unwrap()
3059 .unwrap();
3060 assert!(got.is_terminal());
3061 assert_eq!(got.result.unwrap().status, 200);
3062
3063 assert!(store
3065 .get_idempotency(ProjectRef::DEFAULT, "greeter", "absent")
3066 .await
3067 .unwrap()
3068 .is_none());
3069 }
3070
3071 #[tokio::test]
3072 async fn notification_ledger_provisions_and_retracts_through_the_store() {
3073 use crate::blob_notify::{ManagedResource, ProvisionTier};
3074 use crate::blob_provision::{ensure_watch, retract_watch, ProvisionError, WatchProvider};
3075 use crate::kv::MemoryKv;
3076
3077 struct StoreMock;
3079 #[async_trait::async_trait]
3080 impl WatchProvider for StoreMock {
3081 fn name(&self) -> &str {
3082 "mock"
3083 }
3084 fn recipe(&self, _prefix: &str) -> String {
3085 String::new()
3086 }
3087 async fn provision(
3088 &self,
3089 _prefix: &str,
3090 ) -> Result<Vec<ManagedResource>, ProvisionError> {
3091 Ok(vec![ManagedResource::new("queue", "q-1")])
3092 }
3093 async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
3094 Ok(true)
3095 }
3096 async fn retract(&self, _res: &[ManagedResource]) -> Result<(), ProvisionError> {
3097 Ok(())
3098 }
3099 }
3100
3101 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3102 assert!(store
3103 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3104 .await
3105 .unwrap()
3106 .is_none());
3107
3108 let provider = StoreMock;
3110 let out = ensure_watch(
3111 &provider,
3112 ProvisionTier::Provision,
3113 "ingest",
3114 "uploads/",
3115 &store,
3116 7,
3117 )
3118 .await
3119 .unwrap();
3120 assert!(matches!(
3121 out,
3122 crate::blob_provision::ProvisionOutcome::Ready
3123 ));
3124 let record = store
3125 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3126 .await
3127 .unwrap()
3128 .expect("the pipeline is recorded in the store ledger");
3129 assert_eq!(record.provider, "mock");
3130 assert_eq!(
3131 store
3132 .list_managed_notifications(ProjectRef::DEFAULT, "ingest")
3133 .await
3134 .unwrap()
3135 .len(),
3136 1
3137 );
3138
3139 retract_watch(&provider, &record, &store).await.unwrap();
3141 assert!(store
3142 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3143 .await
3144 .unwrap()
3145 .is_none());
3146 }
3147
3148 #[tokio::test]
3149 async fn workflow_definition_and_run_storage() {
3150 use crate::kv::MemoryKv;
3151 use crate::workflow::{Step, Workflow, WorkflowRun};
3152
3153 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3154 assert!(store
3155 .get_workflow(ProjectRef::DEFAULT, "etl")
3156 .await
3157 .unwrap()
3158 .is_none());
3159 assert!(store
3160 .list_workflows(ProjectRef::DEFAULT)
3161 .await
3162 .unwrap()
3163 .is_empty());
3164
3165 let wf = Workflow {
3166 name: "etl".into(),
3167 steps: vec![
3168 Step {
3169 id: "a".into(),
3170 function: "extract".into(),
3171 depends_on: vec![],
3172 retry: Default::default(),
3173 compensate: None,
3174 },
3175 Step {
3176 id: "b".into(),
3177 function: "load".into(),
3178 depends_on: vec!["a".into()],
3179 retry: Default::default(),
3180 compensate: None,
3181 },
3182 ],
3183 };
3184 store.put_workflow(ProjectRef::DEFAULT, &wf).await.unwrap();
3185 assert_eq!(
3186 store
3187 .get_workflow(ProjectRef::DEFAULT, "etl")
3188 .await
3189 .unwrap()
3190 .unwrap(),
3191 wf
3192 );
3193 assert_eq!(
3194 store
3195 .list_workflows(ProjectRef::DEFAULT)
3196 .await
3197 .unwrap()
3198 .len(),
3199 1
3200 );
3201
3202 let run = WorkflowRun::start(&wf, "r1", None, 5);
3204 store
3205 .put_workflow_run(ProjectRef::DEFAULT, &run)
3206 .await
3207 .unwrap();
3208 assert_eq!(
3209 store
3210 .get_workflow_run(ProjectRef::DEFAULT, "etl", "r1")
3211 .await
3212 .unwrap()
3213 .unwrap(),
3214 run
3215 );
3216 assert_eq!(
3217 store
3218 .list_workflow_runs(ProjectRef::DEFAULT, "etl")
3219 .await
3220 .unwrap()
3221 .len(),
3222 1
3223 );
3224 assert_eq!(
3226 store
3227 .list_workflows(ProjectRef::DEFAULT)
3228 .await
3229 .unwrap()
3230 .len(),
3231 1
3232 );
3233
3234 assert!(store
3236 .delete_workflow(ProjectRef::DEFAULT, "etl")
3237 .await
3238 .unwrap());
3239 assert!(store
3240 .get_workflow(ProjectRef::DEFAULT, "etl")
3241 .await
3242 .unwrap()
3243 .is_none());
3244 assert!(!store
3245 .delete_workflow(ProjectRef::DEFAULT, "etl")
3246 .await
3247 .unwrap());
3248 }
3249
3250 #[tokio::test]
3251 async fn function_trigger_storage_round_trips() {
3252 use crate::function::{
3253 Function, FunctionConfig, FunctionTrigger, Lifecycle, Owner, TriggerKind,
3254 };
3255 use crate::kv::MemoryKv;
3256
3257 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3258 let f = Function::new(
3259 "worker",
3260 Owner::Project("acme".into()),
3261 "hashA",
3262 FunctionConfig::default(),
3263 Lifecycle::Independent,
3264 1,
3265 );
3266 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3267 assert!(store
3268 .list_triggers(ProjectRef::DEFAULT, "worker")
3269 .await
3270 .unwrap()
3271 .is_empty());
3272
3273 let cron = FunctionTrigger {
3274 id: "tick".into(),
3275 kind: TriggerKind::Cron {
3276 schedule: "* * * * *".into(),
3277 overlap: Default::default(),
3278 },
3279 last_fired_minute: None,
3280 };
3281 store
3282 .put_trigger(ProjectRef::DEFAULT, "worker", &cron)
3283 .await
3284 .unwrap();
3285 let queue = FunctionTrigger {
3286 id: "jobs".into(),
3287 kind: TriggerKind::Queue {
3288 topic: "jobs".into(),
3289 },
3290 last_fired_minute: None,
3291 };
3292 store
3293 .put_trigger(ProjectRef::DEFAULT, "worker", &queue)
3294 .await
3295 .unwrap();
3296
3297 assert_eq!(
3298 store
3299 .list_triggers(ProjectRef::DEFAULT, "worker")
3300 .await
3301 .unwrap()
3302 .len(),
3303 2
3304 );
3305 assert_eq!(
3306 store
3307 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3308 .await
3309 .unwrap()
3310 .unwrap(),
3311 cron
3312 );
3313 assert_eq!(
3315 store
3316 .list_stored_functions(ProjectRef::DEFAULT)
3317 .await
3318 .unwrap()
3319 .len(),
3320 1
3321 );
3322
3323 let mut fired = cron.clone();
3325 fired.last_fired_minute = Some(42);
3326 store
3327 .put_trigger(ProjectRef::DEFAULT, "worker", &fired)
3328 .await
3329 .unwrap();
3330 assert_eq!(
3331 store
3332 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3333 .await
3334 .unwrap()
3335 .unwrap()
3336 .last_fired_minute,
3337 Some(42)
3338 );
3339
3340 assert!(store
3342 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3343 .await
3344 .unwrap());
3345 assert!(!store
3346 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3347 .await
3348 .unwrap());
3349 assert_eq!(
3350 store
3351 .list_triggers(ProjectRef::DEFAULT, "worker")
3352 .await
3353 .unwrap()
3354 .len(),
3355 1
3356 );
3357 }
3358
3359 #[tokio::test]
3360 async fn function_metering_storage_is_tenant_isolated() {
3361 use crate::function::{Metering, MeteringSample};
3362 use crate::kv::MemoryKv;
3363
3364 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3365 assert!(store
3366 .get_metering(ProjectRef::DEFAULT, "a")
3367 .await
3368 .unwrap()
3369 .is_none());
3370
3371 let mut ma = Metering::new("a");
3372 ma.record(
3373 &MeteringSample {
3374 success: true,
3375 duration_ms: 4,
3376 bytes_in: 1,
3377 bytes_out: 2,
3378 },
3379 10,
3380 );
3381 store.put_metering(ProjectRef::DEFAULT, &ma).await.unwrap();
3382
3383 let mut mb = Metering::new("b");
3384 mb.record(
3385 &MeteringSample {
3386 success: false,
3387 duration_ms: 9,
3388 bytes_in: 0,
3389 bytes_out: 0,
3390 },
3391 11,
3392 );
3393 store.put_metering(ProjectRef::DEFAULT, &mb).await.unwrap();
3394
3395 assert_eq!(
3397 store
3398 .get_metering(ProjectRef::DEFAULT, "a")
3399 .await
3400 .unwrap()
3401 .unwrap()
3402 .successes,
3403 1
3404 );
3405 assert_eq!(
3406 store
3407 .get_metering(ProjectRef::DEFAULT, "b")
3408 .await
3409 .unwrap()
3410 .unwrap()
3411 .failures,
3412 1
3413 );
3414 let all = store.list_metering(ProjectRef::DEFAULT).await.unwrap();
3415 assert_eq!(all.len(), 2);
3416 }
3417
3418 #[tokio::test]
3419 async fn managed_dns_ledger_round_trip_and_retract() {
3420 use crate::dns_managed::{ManagedDns, ManagedRecord};
3421 use crate::kv::MemoryKv;
3422
3423 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3424 assert!(store
3425 .get_managed_dns(
3426 ProjectRef::DEFAULT,
3427 &SiteName::new("blog"),
3428 "www.example.com"
3429 )
3430 .await
3431 .unwrap()
3432 .is_none());
3433
3434 let ledger = ManagedDns::new(
3435 "www.example.com",
3436 "cloudflare",
3437 vec![ManagedRecord {
3438 kind: "A".into(),
3439 name: "www.example.com".into(),
3440 value: "203.0.113.7".into(),
3441 ttl: 300,
3442 }],
3443 10,
3444 );
3445 store
3446 .set_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"), &ledger)
3447 .await
3448 .unwrap();
3449 assert_eq!(
3451 store
3452 .get_managed_dns(
3453 ProjectRef::DEFAULT,
3454 &SiteName::new("blog"),
3455 "WWW.example.com."
3456 )
3457 .await
3458 .unwrap(),
3459 Some(ledger.clone())
3460 );
3461 assert_eq!(
3462 store
3463 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3464 .await
3465 .unwrap(),
3466 vec![ledger]
3467 );
3468
3469 store
3470 .remove_managed_dns(
3471 ProjectRef::DEFAULT,
3472 &SiteName::new("blog"),
3473 "www.example.com",
3474 )
3475 .await
3476 .unwrap();
3477 assert!(store
3478 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3479 .await
3480 .unwrap()
3481 .is_empty());
3482 }
3483
3484 #[tokio::test]
3485 async fn site_config_round_trip_and_host_routing() {
3486 use crate::config::{DomainConfig, SiteConfig};
3487 use crate::kv::MemoryKv;
3488
3489 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3490 let config = SiteConfig {
3491 domains: DomainConfig {
3492 primary: Some("example.com".into()),
3493 aliases: vec!["www.example.com".into()],
3494 wildcards: vec!["*.example.com".into()],
3495 ..Default::default()
3496 },
3497 ..Default::default()
3498 };
3499 store
3500 .set_site_config(ProjectRef::DEFAULT, "blog", &config)
3501 .await
3502 .unwrap();
3503
3504 let resolved = |host: &'static str| {
3505 let store = store.clone();
3506 async move {
3507 store
3508 .resolve_site_by_host(host)
3509 .await
3510 .unwrap()
3511 .map(|o| o.site)
3512 }
3513 };
3514 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);
3519
3520 store
3522 .set_site_config(ProjectRef::DEFAULT, "blog", &SiteConfig::default())
3523 .await
3524 .unwrap();
3525 assert_eq!(resolved("example.com").await, None);
3526 }
3527
3528 #[tokio::test]
3529 async fn site_config_is_content_addressed_and_dedups() {
3530 use crate::config::SiteConfig;
3531 use crate::kv::MemoryKv;
3532
3533 let kv = Arc::new(MemoryKv::new());
3534 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3535
3536 let mut cfg = SiteConfig::default();
3540 cfg.security.https_redirect = true;
3541 store
3543 .set_site_config(ProjectRef::DEFAULT, "s1", &cfg)
3544 .await
3545 .unwrap();
3546 let mut cfg2 = cfg.clone();
3547 cfg2.security.https_redirect = false;
3548 store
3549 .set_site_config(ProjectRef::DEFAULT, "s2", &cfg2)
3550 .await
3551 .unwrap();
3552 store
3553 .set_site_config(ProjectRef::DEFAULT, "s3", &cfg)
3554 .await
3555 .unwrap(); let bodies = kv.list_prefix("siteconfig/").await.unwrap();
3559 assert_eq!(bodies.len(), 2, "s1/s3 share a body; s2 distinct");
3560 let pointers = kv.list_prefix("project/default/site/").await.unwrap();
3561 assert_eq!(pointers.len(), 3);
3562
3563 assert!(
3565 store
3566 .get_site_config(ProjectRef::DEFAULT, "s1")
3567 .await
3568 .unwrap()
3569 .unwrap()
3570 .security
3571 .https_redirect
3572 );
3573 assert_eq!(
3574 store
3575 .get_site_config(ProjectRef::DEFAULT, "missing")
3576 .await
3577 .unwrap(),
3578 None
3579 );
3580
3581 let mut edited = cfg.clone();
3584 edited.security.frame_options = Some("DENY".into());
3585 store
3586 .set_site_config(ProjectRef::DEFAULT, "s1", &edited)
3587 .await
3588 .unwrap();
3589 store.collect_garbage(true).await.unwrap();
3590 assert_eq!(kv.list_prefix("siteconfig/").await.unwrap().len(), 3);
3592 store
3594 .set_site_config(ProjectRef::DEFAULT, "s3", &edited)
3595 .await
3596 .unwrap();
3597 store.collect_garbage(true).await.unwrap();
3598 let remaining = kv.list_prefix("siteconfig/").await.unwrap();
3599 assert_eq!(remaining.len(), 2, "orphaned shared body reclaimed");
3600 assert!(
3602 store
3603 .get_site_config(ProjectRef::DEFAULT, "s1")
3604 .await
3605 .unwrap()
3606 .unwrap()
3607 .security
3608 .https_redirect
3609 );
3610 assert!(
3611 !store
3612 .get_site_config(ProjectRef::DEFAULT, "s2")
3613 .await
3614 .unwrap()
3615 .unwrap()
3616 .security
3617 .https_redirect
3618 );
3619 }
3620
3621 #[tokio::test]
3622 async fn cert_status_lists_domains_and_expiry_without_keys() {
3623 use crate::cert::StoredCert;
3624 use crate::kv::MemoryKv;
3625
3626 let kv = Arc::new(MemoryKv::new());
3627 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3628 for (domain, not_after) in [("b.example.com", 2000u64), ("a.example.com", 1000u64)] {
3630 let cert = StoredCert::new("CHAINPEM", "KEYPEM", not_after);
3631 kv.put(
3632 &crate::cert::cert_key(domain),
3633 serde_json::to_vec(&cert).unwrap(),
3634 )
3635 .await
3636 .unwrap();
3637 }
3638 kv.put("site/x/config", b"{}".to_vec()).await.unwrap();
3639
3640 let status = store.cert_status().await.unwrap();
3641 assert_eq!(status.len(), 2);
3642 assert_eq!(status[0].domain, "a.example.com");
3644 assert_eq!(status[0].not_after_unix, 1000);
3645 assert_eq!(status[1].domain, "b.example.com");
3646 }
3647
3648 #[tokio::test]
3649 async fn domain_verification_gates_attachment() {
3650 use crate::domain_verify::VerificationMethod;
3651
3652 let store = store();
3653
3654 let v1 = store
3656 .start_domain_verification(
3657 ProjectRef::DEFAULT,
3658 &SiteName::new("blog"),
3659 "example.com",
3660 VerificationMethod::Dns,
3661 100,
3662 )
3663 .await
3664 .unwrap();
3665 let v2 = store
3666 .start_domain_verification(
3667 ProjectRef::DEFAULT,
3668 &SiteName::new("blog"),
3669 "example.com",
3670 VerificationMethod::Dns,
3671 200,
3672 )
3673 .await
3674 .unwrap();
3675 assert_eq!(v1.token, v2.token, "same method → same pending token");
3676 assert!(!store
3677 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3678 .await
3679 .unwrap());
3680
3681 assert!(store
3683 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3684 .await
3685 .is_err());
3686
3687 store
3689 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3690 .await
3691 .unwrap();
3692 assert!(store
3693 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3694 .await
3695 .unwrap());
3696 store
3697 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3698 .await
3699 .unwrap();
3700 assert_eq!(
3701 store
3702 .resolve_site_by_host("example.com")
3703 .await
3704 .unwrap()
3705 .map(|o| o.site)
3706 .as_deref(),
3707 Some("blog")
3708 );
3709 store
3711 .start_domain_verification(
3712 ProjectRef::DEFAULT,
3713 &SiteName::new("blog"),
3714 "www.example.com",
3715 VerificationMethod::Http,
3716 300,
3717 )
3718 .await
3719 .unwrap();
3720 store
3721 .mark_domain_verified(
3722 ProjectRef::DEFAULT,
3723 &SiteName::new("blog"),
3724 "www.example.com",
3725 )
3726 .await
3727 .unwrap();
3728 let config = store
3729 .attach_verified_domain(
3730 ProjectRef::DEFAULT,
3731 &SiteName::new("blog"),
3732 "www.example.com",
3733 )
3734 .await
3735 .unwrap();
3736 assert_eq!(config.domains.primary.as_deref(), Some("example.com"));
3737 assert_eq!(config.domains.aliases, vec!["www.example.com".to_string()]);
3738
3739 store
3741 .start_domain_verification(
3742 ProjectRef::DEFAULT,
3743 &SiteName::new("blog"),
3744 "*.example.com",
3745 VerificationMethod::Dns,
3746 400,
3747 )
3748 .await
3749 .unwrap();
3750 assert!(store
3752 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
3753 .await
3754 .unwrap());
3755 let config = store
3756 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
3757 .await
3758 .unwrap();
3759 assert_eq!(config.domains.wildcards, vec!["*.example.com".to_string()]);
3760
3761 assert_eq!(
3763 store
3764 .list_domain_verifications(ProjectRef::DEFAULT, &SiteName::new("blog"))
3765 .await
3766 .unwrap()
3767 .len(),
3768 2
3769 );
3770 assert!(store
3771 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3772 .await
3773 .unwrap());
3774 assert!(!store
3775 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3776 .await
3777 .unwrap());
3778 }
3779
3780 #[tokio::test]
3784 async fn host_cannot_be_hijacked_across_sites() {
3785 use crate::config::{DomainConfig, SiteConfig};
3786 use crate::domain_verify::VerificationMethod;
3787
3788 let store = store();
3789
3790 store
3792 .start_domain_verification(
3793 ProjectRef::DEFAULT,
3794 &SiteName::new("a"),
3795 "shared.example",
3796 VerificationMethod::Http,
3797 100,
3798 )
3799 .await
3800 .unwrap();
3801 store
3802 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
3803 .await
3804 .unwrap();
3805 store
3806 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
3807 .await
3808 .unwrap();
3809 assert_eq!(
3810 store
3811 .resolve_site_by_host("shared.example")
3812 .await
3813 .unwrap()
3814 .map(|o| o.site)
3815 .as_deref(),
3816 Some("a")
3817 );
3818
3819 store
3823 .start_domain_verification(
3824 ProjectRef::DEFAULT,
3825 &SiteName::new("b"),
3826 "shared.example",
3827 VerificationMethod::Http,
3828 200,
3829 )
3830 .await
3831 .unwrap();
3832 store
3833 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
3834 .await
3835 .unwrap();
3836 let err = store
3837 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
3838 .await
3839 .expect_err("second site must not hijack an attached host");
3840 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
3841
3842 let stolen = SiteConfig {
3845 domains: DomainConfig {
3846 primary: Some("shared.example".into()),
3847 ..Default::default()
3848 },
3849 ..Default::default()
3850 };
3851 let err = store
3852 .set_site_config(ProjectRef::DEFAULT, "b", &stolen)
3853 .await
3854 .expect_err("set_site_config must refuse another site's host");
3855 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
3856
3857 assert_eq!(
3859 store
3860 .resolve_site_by_host("shared.example")
3861 .await
3862 .unwrap()
3863 .map(|o| o.site)
3864 .as_deref(),
3865 Some("a")
3866 );
3867
3868 let readd = SiteConfig {
3871 domains: DomainConfig {
3872 primary: Some("shared.example".into()),
3873 aliases: vec!["www.shared.example".into()],
3874 ..Default::default()
3875 },
3876 ..Default::default()
3877 };
3878 store
3879 .set_site_config(ProjectRef::DEFAULT, "a", &readd)
3880 .await
3881 .unwrap();
3882 assert_eq!(
3883 store
3884 .resolve_site_by_host("www.shared.example")
3885 .await
3886 .unwrap()
3887 .map(|o| o.site)
3888 .as_deref(),
3889 Some("a")
3890 );
3891 }
3892
3893 #[tokio::test]
3897 async fn host_uniqueness_is_case_and_dot_insensitive() {
3898 use crate::config::{DomainConfig, SiteConfig};
3899 use crate::domain_verify::VerificationMethod;
3900
3901 let store = store();
3902 store
3904 .start_domain_verification(
3905 ProjectRef::DEFAULT,
3906 &SiteName::new("a"),
3907 "example.com",
3908 VerificationMethod::Http,
3909 100,
3910 )
3911 .await
3912 .unwrap();
3913 store
3914 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
3915 .await
3916 .unwrap();
3917 store
3918 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
3919 .await
3920 .unwrap();
3921
3922 for variant in ["Example.COM", "example.com.", "EXAMPLE.com."] {
3924 let cfg = SiteConfig {
3925 domains: DomainConfig {
3926 primary: Some(variant.into()),
3927 ..Default::default()
3928 },
3929 ..Default::default()
3930 };
3931 let err = store
3932 .set_site_config(ProjectRef::DEFAULT, "b", &cfg)
3933 .await
3934 .expect_err("variant claim must be refused");
3935 assert!(
3936 matches!(err, DeployError::Conflict(_)),
3937 "variant {variant:?} must Conflict, got {err:?}"
3938 );
3939 }
3940
3941 for h in ["example.com", "Example.com", "EXAMPLE.COM", "example.com."] {
3943 assert_eq!(
3944 store
3945 .resolve_site_by_host(h)
3946 .await
3947 .unwrap()
3948 .map(|o| o.site)
3949 .as_deref(),
3950 Some("a"),
3951 "host {h:?} must resolve to site a"
3952 );
3953 }
3954 }
3955
3956 #[tokio::test]
3960 async fn self_serve_challenge_lookup_matches_pending_http_only() {
3961 use crate::domain_verify::{VerificationMethod, CHALLENGE_TTL_SECS};
3962
3963 let store = store();
3964 let v = store
3965 .start_domain_verification(
3966 ProjectRef::DEFAULT,
3967 &SiteName::new("docs"),
3968 "docs.example",
3969 VerificationMethod::Http,
3970 1_000,
3971 )
3972 .await
3973 .unwrap();
3974
3975 let found = store
3977 .find_pending_http_challenge("docs.example", &v.token, 1_000)
3978 .await
3979 .unwrap();
3980 assert_eq!(
3981 found.as_ref().map(|f| f.token.clone()),
3982 Some(v.token.clone())
3983 );
3984 assert!(store
3986 .find_pending_http_challenge("Docs.Example.", &v.token, 1_000)
3987 .await
3988 .unwrap()
3989 .is_some());
3990
3991 assert!(store
3993 .find_pending_http_challenge("docs.example", "not-the-token", 1_000)
3994 .await
3995 .unwrap()
3996 .is_none());
3997 assert!(store
3998 .find_pending_http_challenge("other.example", &v.token, 1_000)
3999 .await
4000 .unwrap()
4001 .is_none());
4002
4003 assert!(store
4005 .find_pending_http_challenge("docs.example", &v.token, 1_000 + CHALLENGE_TTL_SECS + 1)
4006 .await
4007 .unwrap()
4008 .is_none());
4009
4010 let dv = store
4012 .start_domain_verification(
4013 ProjectRef::DEFAULT,
4014 &SiteName::new("dns-site"),
4015 "dns.example",
4016 VerificationMethod::Dns,
4017 1_000,
4018 )
4019 .await
4020 .unwrap();
4021 assert!(store
4022 .find_pending_http_challenge("dns.example", &dv.token, 1_000)
4023 .await
4024 .unwrap()
4025 .is_none());
4026 }
4027
4028 #[tokio::test]
4032 async fn wildcard_requires_dns_and_stale_index_is_safe() {
4033 use crate::domain_verify::VerificationMethod;
4034
4035 let store = store();
4036
4037 let http = store
4039 .start_domain_verification(
4040 ProjectRef::DEFAULT,
4041 &SiteName::new("s"),
4042 "*.example.com",
4043 VerificationMethod::Http,
4044 100,
4045 )
4046 .await
4047 .unwrap();
4048 store
4049 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4050 .await
4051 .unwrap();
4052 let err = store
4053 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4054 .await
4055 .expect_err("wildcard with only HTTP proof must be refused");
4056 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4057
4058 store
4062 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4063 .await
4064 .unwrap();
4065 assert!(store
4067 .find_pending_http_challenge("example.com", &http.token, 100)
4068 .await
4069 .unwrap()
4070 .is_none());
4071 store
4072 .start_domain_verification(
4073 ProjectRef::DEFAULT,
4074 &SiteName::new("s"),
4075 "*.example.com",
4076 VerificationMethod::Dns,
4077 200,
4078 )
4079 .await
4080 .unwrap();
4081 store
4082 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4083 .await
4084 .unwrap();
4085 let cfg = store
4086 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4087 .await
4088 .unwrap();
4089 assert_eq!(cfg.domains.wildcards, vec!["*.example.com".to_string()]);
4090
4091 let h2 = store
4095 .start_domain_verification(
4096 ProjectRef::DEFAULT,
4097 &SiteName::new("s2"),
4098 "host.example",
4099 VerificationMethod::Http,
4100 300,
4101 )
4102 .await
4103 .unwrap();
4104 assert!(store
4105 .find_pending_http_challenge("host.example", &h2.token, 300)
4106 .await
4107 .unwrap()
4108 .is_some());
4109 store
4110 .start_domain_verification(
4111 ProjectRef::DEFAULT,
4112 &SiteName::new("s2"),
4113 "host.example",
4114 VerificationMethod::Dns,
4115 300,
4116 )
4117 .await
4118 .unwrap();
4119 assert!(
4120 store
4121 .find_pending_http_challenge("host.example", &h2.token, 300)
4122 .await
4123 .unwrap()
4124 .is_none(),
4125 "a stale HTTP index must not serve a token whose record is now DNS"
4126 );
4127 }
4128
4129 #[tokio::test]
4130 async fn pending_verifications_are_capped_per_site() {
4131 let store = store();
4132 for i in 0..64 {
4134 store
4135 .start_domain_verification(
4136 ProjectRef::DEFAULT,
4137 &SiteName::new("site"),
4138 &format!("h{i}.example"),
4139 VerificationMethod::Http,
4140 100,
4141 )
4142 .await
4143 .unwrap();
4144 }
4145 let err = store
4147 .start_domain_verification(
4148 ProjectRef::DEFAULT,
4149 &SiteName::new("site"),
4150 "overflow.example",
4151 VerificationMethod::Http,
4152 100,
4153 )
4154 .await
4155 .expect_err("the 65th pending host must be rejected");
4156 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4157 store
4159 .start_domain_verification(
4160 ProjectRef::DEFAULT,
4161 &SiteName::new("site"),
4162 "h0.example",
4163 VerificationMethod::Http,
4164 100,
4165 )
4166 .await
4167 .expect("re-running an existing challenge is not capped");
4168 store
4170 .start_domain_verification(
4171 ProjectRef::DEFAULT,
4172 &SiteName::new("other"),
4173 "fresh.example",
4174 VerificationMethod::Http,
4175 100,
4176 )
4177 .await
4178 .expect("a different site is unaffected");
4179 }
4180
4181 fn store() -> DeployStore {
4182 use crate::kv::MemoryKv;
4183 DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
4184 }
4185
4186 #[tokio::test]
4187 async fn default_project_is_visible_on_a_fresh_store_and_ensure_is_idempotent() {
4188 let s = store();
4189 let default = crate::project::DEFAULT_PROJECT;
4190
4191 assert!(
4193 s.get_project(default).await.unwrap().is_some(),
4194 "`project show default` must never 404, even on a fresh store"
4195 );
4196 let listed = s.list_projects().await.unwrap();
4197 assert_eq!(
4198 listed.iter().filter(|p| p.name == default).count(),
4199 1,
4200 "`project ls` must show exactly one `default` on a fresh store"
4201 );
4202 assert!(s.get_project("nope").await.unwrap().is_none());
4204
4205 assert!(s.project_exists(default).await.unwrap());
4208 assert!(!s.project_exists("nope").await.unwrap());
4209
4210 assert!(
4212 s.ensure_default_project().await.unwrap(),
4213 "first ensure creates"
4214 );
4215 assert!(
4216 !s.ensure_default_project().await.unwrap(),
4217 "second ensure is idempotent (presence-checked)"
4218 );
4219
4220 let listed = s.list_projects().await.unwrap();
4222 assert_eq!(
4223 listed.iter().filter(|p| p.name == default).count(),
4224 1,
4225 "materializing `default` must not duplicate it in the listing"
4226 );
4227 assert_eq!(s.get_project(default).await.unwrap().unwrap().name, default);
4228 }
4229
4230 #[tokio::test]
4231 async fn daemon_config_store_round_trips_and_rolls_back() {
4232 use crate::daemon_config::DaemonConfig;
4233 let s = store();
4234 assert!(s.get_daemon_config().await.unwrap().is_none());
4236 assert!(s.daemon_config_generation().await.unwrap().is_none());
4237
4238 let g1cfg = DaemonConfig {
4240 default_site: Some("one".into()),
4241 ..Default::default()
4242 };
4243 let g1 = s.set_daemon_config(&g1cfg).await.unwrap();
4244 assert_eq!(
4245 s.daemon_config_generation().await.unwrap().as_deref(),
4246 Some(g1.as_str())
4247 );
4248 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4249 assert!(s.daemon_config_history().await.unwrap().is_empty());
4250
4251 let g2cfg = DaemonConfig {
4253 default_site: Some("two".into()),
4254 ..Default::default()
4255 };
4256 let g2 = s.set_daemon_config(&g2cfg).await.unwrap();
4257 assert_ne!(g1, g2);
4258 assert_eq!(s.daemon_config_history().await.unwrap(), vec![g1.clone()]);
4259
4260 let rolled = s.rollback_daemon_config().await.unwrap();
4262 assert_eq!(rolled.as_deref(), Some(g1.as_str()));
4263 assert_eq!(
4264 s.daemon_config_generation().await.unwrap().as_deref(),
4265 Some(g1.as_str())
4266 );
4267 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4268 assert!(s.rollback_daemon_config().await.unwrap().is_none());
4270 }
4271
4272 #[tokio::test]
4273 async fn compute_store_round_trips() {
4274 use crate::compute::{
4275 ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
4276 };
4277 let s = store();
4278 let spec = ComputeSpec {
4279 version: crate::SCHEMA_VERSION,
4280 root: RootSource::Rootfs("r".repeat(64)),
4281 kernel: "k".repeat(64),
4282 kernel_cmdline: None,
4283 vcpus: 1,
4284 mem_mib: 256,
4285 entrypoint: vec!["/app".into()],
4286 env: Default::default(),
4287 port: 8080,
4288 restart: RestartPolicy::Always,
4289 scale_to_zero: false,
4290 volumes: vec![],
4291 writable_root: false,
4292 cap_add: Vec::new(),
4293 user: None,
4294 isolation: Default::default(),
4295 prefer_backend: None,
4296 bindings: vec![],
4297 };
4298 let hash = s.put_compute_spec(&spec).await.unwrap();
4300 assert_eq!(hash, spec.id());
4301 assert_eq!(s.get_compute_spec(&hash).await.unwrap(), Some(spec));
4302 assert!(s.get_compute_spec("deadbeef").await.unwrap().is_none());
4303
4304 let workload = ComputeWorkload {
4306 version: crate::SCHEMA_VERSION,
4307 name: "api".into(),
4308 active: hash.clone(),
4309 replicas: 3,
4310 placement: PlacementConstraints::default(),
4311 };
4312 s.set_compute_workload(ProjectRef::DEFAULT, &workload)
4313 .await
4314 .unwrap();
4315 assert_eq!(
4316 s.get_compute_workload(ProjectRef::DEFAULT, "api")
4317 .await
4318 .unwrap(),
4319 Some(workload)
4320 );
4321 assert_eq!(
4322 s.list_compute_workloads(ProjectRef::DEFAULT)
4323 .await
4324 .unwrap()
4325 .len(),
4326 1
4327 );
4328 assert!(s
4329 .delete_compute_workload(ProjectRef::DEFAULT, "api")
4330 .await
4331 .unwrap());
4332 assert!(!s
4333 .delete_compute_workload(ProjectRef::DEFAULT, "api")
4334 .await
4335 .unwrap());
4336 assert!(s
4337 .list_compute_workloads(ProjectRef::DEFAULT)
4338 .await
4339 .unwrap()
4340 .is_empty());
4341 }
4342
4343 fn empty_manifest(clean_urls: bool) -> Manifest {
4346 Manifest {
4347 config: DeployConfig {
4348 clean_urls,
4349 ..DeployConfig::default()
4350 },
4351 ..Default::default()
4352 }
4353 }
4354
4355 #[tokio::test]
4356 async fn deploy_meta_records_sizes_and_merges_provenance() {
4357 let store = store();
4358 let mut manifest = Manifest::default();
4359 manifest.files.insert("index.html".into(), {
4360 let mut e = entry("aa");
4361 e.size = 10;
4362 e
4363 });
4364 manifest.files.insert("app.js".into(), {
4365 let mut e = entry("bb");
4366 e.size = 32;
4367 e
4368 });
4369
4370 let id = store
4372 .put_manifest_with(
4373 &manifest,
4374 DeployMetaInput {
4375 source: Some("abc123".into()),
4376 message: Some("first".into()),
4377 ..Default::default()
4378 },
4379 )
4380 .await
4381 .unwrap();
4382 let meta = store.get_meta(&id).await.unwrap().unwrap();
4383 assert_eq!(meta.file_count, 2);
4384 assert_eq!(meta.total_size, 42);
4385 assert_eq!(meta.source.as_deref(), Some("abc123"));
4386 let created = meta.created_at;
4387
4388 store
4390 .put_manifest_with(&manifest, DeployMetaInput::default())
4391 .await
4392 .unwrap();
4393 let meta = store.get_meta(&id).await.unwrap().unwrap();
4394 assert_eq!(meta.created_at, created);
4395 assert_eq!(meta.source.as_deref(), Some("abc123"));
4396 assert_eq!(meta.message.as_deref(), Some("first"));
4397 }
4398
4399 #[tokio::test]
4400 async fn aliases_round_trip_and_guard_completeness() {
4401 let store = store();
4402 let manifest = empty_manifest(false);
4403 let id = store.put_manifest(&manifest).await.unwrap();
4404
4405 assert!(matches!(
4407 store
4408 .set_alias(ProjectRef::DEFAULT, "blog", "staging", "deadbeef")
4409 .await,
4410 Err(DeployError::NotFound(_))
4411 ));
4412
4413 store
4414 .set_alias(ProjectRef::DEFAULT, "blog", "staging", &id)
4415 .await
4416 .unwrap();
4417 assert_eq!(
4418 store
4419 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4420 .await
4421 .unwrap(),
4422 Some(id.clone())
4423 );
4424 let aliases = store
4425 .list_aliases(ProjectRef::DEFAULT, "blog")
4426 .await
4427 .unwrap();
4428 assert_eq!(aliases.get("staging"), Some(&id));
4429
4430 assert!(store
4431 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4432 .await
4433 .unwrap());
4434 assert!(!store
4435 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4436 .await
4437 .unwrap());
4438 assert_eq!(
4439 store
4440 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4441 .await
4442 .unwrap(),
4443 None
4444 );
4445 }
4446
4447 #[tokio::test]
4448 async fn retention_keep_last_collects_older_history() {
4449 let store = store();
4450 let m1 = empty_manifest(false);
4452 let m2 = empty_manifest(true);
4453 let mut m3 = empty_manifest(true);
4454 m3.config.trailing_slash = crate::config::TrailingSlash::Always;
4455 let id1 = store.put_manifest(&m1).await.unwrap();
4456 let id2 = store.put_manifest(&m2).await.unwrap();
4457 let id3 = store.put_manifest(&m3).await.unwrap();
4458 store
4459 .activate(ProjectRef::DEFAULT, "blog", &id1)
4460 .await
4461 .unwrap();
4462 store
4463 .activate(ProjectRef::DEFAULT, "blog", &id2)
4464 .await
4465 .unwrap();
4466 store
4467 .activate(ProjectRef::DEFAULT, "blog", &id3)
4468 .await
4469 .unwrap();
4470
4471 let report = store.collect_garbage(false).await.unwrap();
4473 assert_eq!(report.manifests_removed, 0);
4474
4475 store
4478 .set_alias(ProjectRef::DEFAULT, "blog", "pinned", &id1)
4479 .await
4480 .unwrap();
4481 let report = store
4482 .collect_garbage_with(
4483 false,
4484 GcOptions {
4485 keep_last: Some(1),
4486 ..Default::default()
4487 },
4488 )
4489 .await
4490 .unwrap();
4491 assert_eq!(report.manifests_removed, 1); }
4493
4494 #[tokio::test]
4495 async fn grace_window_protects_in_flight_manifest() {
4496 let store = store();
4497 let id = store.put_manifest(&empty_manifest(false)).await.unwrap();
4499 assert!(store.get_manifest(&id).await.unwrap().is_some());
4500
4501 let report = store
4503 .collect_garbage_with(
4504 false,
4505 GcOptions {
4506 grace_secs: 3600,
4507 ..Default::default()
4508 },
4509 )
4510 .await
4511 .unwrap();
4512 assert_eq!(report.manifests_removed, 0);
4513
4514 let report = store.collect_garbage(false).await.unwrap();
4516 assert_eq!(report.manifests_removed, 1);
4517 }
4518
4519 #[tokio::test]
4520 async fn resolve_manifest_id_exact_prefix_and_missing() {
4521 let store = store();
4522 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4523
4524 assert_eq!(
4526 store.resolve_manifest_id(&id).await.unwrap().as_deref(),
4527 Some(id.as_str())
4528 );
4529 assert_eq!(
4531 store
4532 .resolve_manifest_id(&id[..16])
4533 .await
4534 .unwrap()
4535 .as_deref(),
4536 Some(id.as_str())
4537 );
4538 assert!(store
4540 .resolve_manifest_id("ffffffffffffffff")
4541 .await
4542 .unwrap()
4543 .is_none());
4544 }
4545
4546 #[tokio::test]
4547 async fn delete_site_removes_config_routing_and_aliases() {
4548 let store = store();
4549 let mut cfg = SiteConfig::default();
4550 cfg.domains.primary = Some("blog.example".into());
4551 cfg.domains.wildcards = vec!["*.preview.blog.example".into()];
4552 store
4553 .set_site_config(ProjectRef::DEFAULT, "blog", &cfg)
4554 .await
4555 .unwrap();
4556 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4558 store
4559 .set_alias(ProjectRef::DEFAULT, "blog", "stable", &id)
4560 .await
4561 .unwrap();
4562
4563 assert!(store
4565 .get_site_config(ProjectRef::DEFAULT, "blog")
4566 .await
4567 .unwrap()
4568 .is_some());
4569 assert_eq!(
4570 store
4571 .resolve_site_by_host("blog.example")
4572 .await
4573 .unwrap()
4574 .map(|o| o.site)
4575 .as_deref(),
4576 Some("blog")
4577 );
4578
4579 store
4580 .delete_site(ProjectRef::DEFAULT, "blog")
4581 .await
4582 .unwrap();
4583
4584 assert!(store
4586 .get_site_config(ProjectRef::DEFAULT, "blog")
4587 .await
4588 .unwrap()
4589 .is_none());
4590 assert!(store
4591 .resolve_site_by_host("blog.example")
4592 .await
4593 .unwrap()
4594 .is_none());
4595 assert!(store
4596 .list_aliases(ProjectRef::DEFAULT, "blog")
4597 .await
4598 .unwrap()
4599 .is_empty());
4600
4601 store
4603 .delete_site(ProjectRef::DEFAULT, "blog")
4604 .await
4605 .unwrap();
4606 }
4607
4608 #[tokio::test]
4609 async fn gc_blob_reachability_is_a_cross_project_union() {
4610 use crate::kv::MemoryKv;
4611 let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
4613
4614 let shared = sha256_hex(b"shared-blob");
4618 let keep = sha256_hex(b"keep-blob");
4619 let dead = sha256_hex(b"dead-blob");
4620 store
4621 .put_blob(&shared, once_bytes(b"shared-blob"))
4622 .await
4623 .unwrap();
4624 store
4625 .put_blob(&keep, once_bytes(b"keep-blob"))
4626 .await
4627 .unwrap();
4628 store
4629 .put_blob(&dead, once_bytes(b"dead-blob"))
4630 .await
4631 .unwrap();
4632
4633 let m_shared = manifest_with(&[("index.html", &shared)]);
4634 let m_keep = manifest_with(&[("index.html", &keep)]);
4635 let m_dead = manifest_with(&[("old.html", &dead)]);
4636 let id_shared = store.put_manifest(&m_shared).await.unwrap();
4637 let id_keep = store.put_manifest(&m_keep).await.unwrap();
4638 let id_dead = store.put_manifest(&m_dead).await.unwrap();
4639
4640 let acme = ProjectRef::new("acme");
4642 store.activate(acme, "site", &id_shared).await.unwrap();
4643 let shop = ProjectRef::new("shop");
4647 store.activate(shop, "site", &id_shared).await.unwrap();
4648 store.activate(shop, "site", &id_keep).await.unwrap();
4649 let report = store
4652 .collect_garbage_with(
4653 true,
4654 GcOptions {
4655 keep_last: Some(1),
4656 ..Default::default()
4657 },
4658 )
4659 .await
4660 .unwrap();
4661
4662 assert!(store.get_manifest(&id_dead).await.unwrap().is_none());
4664 assert!(
4665 !store.has_blob(&dead).await.unwrap(),
4666 "dead blob is reclaimed"
4667 );
4668 assert_eq!(report.manifests_removed, 1, "only the dead manifest goes");
4669
4670 assert!(
4673 store.get_manifest(&id_shared).await.unwrap().is_some(),
4674 "shared manifest kept by acme's reference"
4675 );
4676 assert!(
4677 store.has_blob(&shared).await.unwrap(),
4678 "shared blob kept — reachability is the union across projects"
4679 );
4680 assert!(store.has_blob(&keep).await.unwrap());
4682 assert_eq!(
4683 store.current_id(acme, "site").await.unwrap().as_deref(),
4684 Some(id_shared.as_str())
4685 );
4686 }
4687
4688 #[tokio::test]
4689 async fn same_site_name_in_two_projects_is_isolated() {
4690 use crate::kv::MemoryKv;
4691 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4692 let acme = ProjectRef::new("acme");
4693 let shop = ProjectRef::new("shop");
4694
4695 let id_a = store.put_manifest(&empty_manifest(false)).await.unwrap();
4697 let id_b = store.put_manifest(&empty_manifest(true)).await.unwrap();
4698 store.activate(acme, "www", &id_a).await.unwrap();
4699 store.activate(shop, "www", &id_b).await.unwrap();
4700
4701 assert_eq!(
4703 store.current_id(acme, "www").await.unwrap().as_deref(),
4704 Some(id_a.as_str())
4705 );
4706 assert_eq!(
4707 store.current_id(shop, "www").await.unwrap().as_deref(),
4708 Some(id_b.as_str())
4709 );
4710 assert_ne!(id_a, id_b);
4711
4712 let mut cfg_a = SiteConfig::default();
4714 cfg_a.domains.primary = Some("acme.example".into());
4715 store.set_site_config(acme, "www", &cfg_a).await.unwrap();
4716 let mut cfg_b = SiteConfig::default();
4717 cfg_b.domains.primary = Some("shop.example".into());
4718 store.set_site_config(shop, "www", &cfg_b).await.unwrap();
4719 store
4720 .set_alias(acme, "www", "staging", &id_a)
4721 .await
4722 .unwrap();
4723
4724 assert_eq!(
4725 store
4726 .get_site_config(acme, "www")
4727 .await
4728 .unwrap()
4729 .unwrap()
4730 .domains
4731 .primary
4732 .as_deref(),
4733 Some("acme.example")
4734 );
4735 assert_eq!(
4736 store
4737 .get_site_config(shop, "www")
4738 .await
4739 .unwrap()
4740 .unwrap()
4741 .domains
4742 .primary
4743 .as_deref(),
4744 Some("shop.example")
4745 );
4746 assert!(store
4748 .get_alias(acme, "www", "staging")
4749 .await
4750 .unwrap()
4751 .is_some());
4752 assert!(store
4753 .get_alias(shop, "www", "staging")
4754 .await
4755 .unwrap()
4756 .is_none());
4757 assert!(store.list_aliases(shop, "www").await.unwrap().is_empty());
4758
4759 assert_eq!(
4761 store.resolve_site_by_host("acme.example").await.unwrap(),
4762 Some(DomainOwner::new("acme", "www"))
4763 );
4764 assert_eq!(
4765 store.resolve_site_by_host("shop.example").await.unwrap(),
4766 Some(DomainOwner::new("shop", "www"))
4767 );
4768 store.delete_site(acme, "www").await.unwrap();
4769 assert!(store.get_site_config(acme, "www").await.unwrap().is_none());
4770 assert!(
4771 store.get_site_config(shop, "www").await.unwrap().is_some(),
4772 "deleting acme/www must not touch shop/www"
4773 );
4774 assert!(store.current_id(shop, "www").await.unwrap().is_some());
4775 }
4776
4777 #[tokio::test]
4778 async fn project_entity_crud_and_delete_guard() {
4779 use crate::project::Project;
4780 let store = store();
4781 let acme = Project {
4782 version: crate::SCHEMA_VERSION,
4783 name: "acme".into(),
4784 created_at: 1,
4785 meta: Default::default(),
4786 config: Default::default(),
4787 secrets_ref: None,
4788 };
4789 let hash = store.put_project(&acme).await.unwrap();
4791 assert_eq!(hash, acme.id());
4792 assert_eq!(store.get_project("acme").await.unwrap(), Some(acme.clone()));
4793 assert!(store.get_project("ghost").await.unwrap().is_none());
4794 let names: Vec<String> = store
4796 .list_projects()
4797 .await
4798 .unwrap()
4799 .into_iter()
4800 .map(|p| p.name)
4801 .collect();
4802 assert_eq!(names, vec!["acme".to_string(), "default".to_string()]);
4803
4804 store
4806 .set_site_config(ProjectRef::new("acme"), "www", &SiteConfig::default())
4807 .await
4808 .unwrap();
4809 assert!(matches!(
4810 store.delete_project("acme").await,
4811 Err(DeployError::Conflict(_))
4812 ));
4813 store
4815 .delete_site(ProjectRef::new("acme"), "www")
4816 .await
4817 .unwrap();
4818 assert!(store.delete_project("acme").await.unwrap());
4819 assert!(store.get_project("acme").await.unwrap().is_none());
4820
4821 assert!(matches!(
4823 store.delete_project("default").await,
4824 Err(DeployError::Conflict(_))
4825 ));
4826 }
4827}