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 site_config_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, Arc<SiteConfig>>>>,
118 blob_body_cache: Arc<std::sync::RwLock<BlobBodyCache>>,
125}
126
127pub enum BlobBody {
130 Cached(bytes::Bytes),
132 Stream(GetObject),
134}
135
136#[derive(Default)]
141struct BlobBodyCache {
142 map: std::collections::HashMap<String, bytes::Bytes>,
143 bytes: usize,
144}
145
146const SMALL_BLOB_CACHE_MAX: u64 = 256 * 1024;
149const BLOB_BODY_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
151
152mod keys {
153 use crate::project::ProjectRef;
170
171 pub fn manifest(id: &str) -> String {
173 format!("manifests/{id}")
174 }
175
176 pub fn meta(id: &str) -> String {
178 format!("meta/{id}")
179 }
180
181 pub fn current(project: ProjectRef<'_>, site: &str) -> String {
183 format!("project/{project}/current/{site}")
184 }
185
186 pub fn current_prefix(project: ProjectRef<'_>) -> String {
188 format!("project/{project}/current/")
189 }
190
191 pub fn alias(project: ProjectRef<'_>, site: &str, name: &str) -> String {
193 format!("project/{project}/alias/{site}/{name}")
194 }
195
196 pub fn alias_prefix(project: ProjectRef<'_>, site: &str) -> String {
198 format!("project/{project}/alias/{site}/")
199 }
200
201 pub fn alias_project_prefix(project: ProjectRef<'_>) -> String {
203 format!("project/{project}/alias/")
204 }
205
206 pub fn blob(hash: &str) -> String {
208 if hash.len() >= 2 {
209 format!("{}/{}", &hash[..2], hash)
210 } else {
211 hash.to_string()
212 }
213 }
214
215 pub fn site_pointer(project: ProjectRef<'_>, site: &str) -> String {
219 format!("project/{project}/site/{site}")
220 }
221
222 pub fn site_prefix(project: ProjectRef<'_>) -> String {
224 format!("project/{project}/site/")
225 }
226
227 pub fn site_config_blob(hash: &str) -> String {
232 format!("siteconfig/{hash}")
233 }
234
235 pub fn domain(host: &str) -> String {
239 format!("domain/{}", super::canon_host(host))
240 }
241
242 pub fn wildcard(suffix: &str) -> String {
245 format!("wildcard/{}", super::canon_host(suffix))
246 }
247
248 pub fn domain_verification(project: ProjectRef<'_>, site: &str, host: &str) -> String {
251 format!(
252 "project/{project}/domainverify/{site}/{}",
253 crate::domain_verify::normalize_host(host)
254 )
255 }
256
257 pub fn domain_verification_prefix(project: ProjectRef<'_>, site: &str) -> String {
259 format!("project/{project}/domainverify/{site}/")
260 }
261
262 pub fn domain_verification_project_prefix(project: ProjectRef<'_>) -> String {
264 format!("project/{project}/domainverify/")
265 }
266
267 pub fn http_challenge_index(host: &str, token: &str) -> String {
273 format!(
274 "httpchallenge/{}/{token}",
275 crate::domain_verify::normalize_host(host)
276 )
277 }
278
279 pub fn daemon_config_blob(hash: &str) -> String {
282 format!("daemonconfig/{hash}")
283 }
284
285 pub fn history(project: ProjectRef<'_>, site: &str) -> String {
287 format!("project/{project}/history/{site}")
288 }
289
290 pub fn history_prefix(project: ProjectRef<'_>) -> String {
292 format!("project/{project}/history/")
293 }
294
295 pub const PROJECT_ROOT: &str = "project/";
299
300 pub fn project_of_key(key: &str) -> Option<&str> {
303 key.strip_prefix(PROJECT_ROOT)
304 .and_then(|rest| rest.split('/').next())
305 .filter(|p| !p.is_empty())
306 }
307}
308
309impl DeployStore {
310 pub fn new(storage: Arc<dyn Storage>, kv: Arc<dyn KvStore>) -> Self {
312 Self {
313 storage,
314 kv,
315 domain_claim_lock: Arc::new(futures::lock::Mutex::new(())),
316 site_config_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
317 blob_body_cache: Arc::new(std::sync::RwLock::new(BlobBodyCache::default())),
318 }
319 }
320
321 pub fn kv(&self) -> &Arc<dyn KvStore> {
324 &self.kv
325 }
326
327 pub async fn ready(&self) -> Result<(), DeployError> {
331 self.kv.get("__readyz_probe__").await?;
332 Ok(())
333 }
334
335 pub async fn put_manifest(&self, manifest: &Manifest) -> Result<String, DeployError> {
337 self.put_manifest_with(manifest, DeployMetaInput::default())
338 .await
339 }
340
341 pub async fn put_manifest_with(
349 &self,
350 manifest: &Manifest,
351 input: DeployMetaInput,
352 ) -> Result<String, DeployError> {
353 let id = manifest.id()?;
354
355 let existing = self.get_meta(&id).await?;
359 let created_at = existing
360 .as_ref()
361 .map(|m| m.created_at)
362 .unwrap_or_else(now_unix);
363 let meta = DeployMeta {
364 version: crate::SCHEMA_VERSION,
365 created_at,
366 file_count: manifest.files.len() as u64,
367 total_size: manifest.files.values().map(|entry| entry.size).sum(),
368 source: input
369 .source
370 .or_else(|| existing.as_ref().and_then(|m| m.source.clone())),
371 branch: input
372 .branch
373 .or_else(|| existing.as_ref().and_then(|m| m.branch.clone())),
374 author: input
375 .author
376 .or_else(|| existing.as_ref().and_then(|m| m.author.clone())),
377 message: input
378 .message
379 .or_else(|| existing.as_ref().and_then(|m| m.message.clone())),
380 tag: input
381 .tag
382 .or_else(|| existing.as_ref().and_then(|m| m.tag.clone())),
383 tags: if input.tags.is_empty() {
386 existing.map(|m| m.tags).unwrap_or_default()
387 } else {
388 input.tags
389 },
390 };
391 self.kv
392 .write_batch(vec![
393 WriteOp::Put(keys::manifest(&id), manifest.to_bytes()?),
394 WriteOp::Put(keys::meta(&id), serde_json::to_vec(&meta)?),
395 ])
396 .await?;
397 Ok(id)
398 }
399
400 pub async fn get_meta(&self, id: &str) -> Result<Option<DeployMeta>, DeployError> {
402 match self.kv.get(&keys::meta(id)).await? {
403 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
404 None => Ok(None),
405 }
406 }
407
408 pub async fn get_manifest(&self, id: &str) -> Result<Option<Manifest>, DeployError> {
410 match self.kv.get(&keys::manifest(id)).await? {
411 Some(bytes) => Ok(Some(Manifest::from_bytes(&bytes)?)),
412 None => Ok(None),
413 }
414 }
415
416 pub async fn resolve_manifest_id(&self, prefix: &str) -> Result<Option<String>, DeployError> {
423 if self.kv.get(&keys::manifest(prefix)).await?.is_some() {
425 return Ok(Some(prefix.to_string()));
426 }
427 let key_prefix = keys::manifest(prefix);
428 let strip = "manifests/".len();
429 let keys = self.kv.list_prefix(&key_prefix).await?;
430 let mut ids = keys.iter().map(|key| &key[strip..]);
431 match (ids.next(), ids.next()) {
432 (Some(only), None) => Ok(Some(only.to_string())),
433 (Some(_), Some(_)) => Err(DeployError::Ambiguous(prefix.to_string())),
434 _ => Ok(None),
435 }
436 }
437
438 pub async fn has_blob(&self, hash: &str) -> Result<bool, DeployError> {
440 match self.storage.head(&keys::blob(hash)).await {
441 Ok(_) => Ok(true),
442 Err(StorageError::NotFound(_)) => Ok(false),
443 Err(err) => Err(err.into()),
444 }
445 }
446
447 pub async fn missing_blobs(&self, manifest: &Manifest) -> Result<Vec<String>, DeployError> {
449 let mut missing = Vec::new();
450 for hash in manifest.blob_hashes() {
451 if !self.has_blob(&hash).await? {
452 missing.push(hash);
453 }
454 }
455 Ok(missing)
456 }
457
458 pub async fn put_blob(&self, hash: &str, body: ByteStream) -> Result<(), DeployError> {
463 let hasher = Arc::new(Mutex::new(Sha256::new()));
464 let tap = hasher.clone();
465 let verified: ByteStream = body
466 .map(move |chunk| {
467 if let Ok(bytes) = &chunk {
468 tap.lock().unwrap().update(bytes);
469 }
470 chunk
471 })
472 .boxed();
473
474 let key = keys::blob(hash);
475 self.storage.put(&key, verified, PutMeta::default()).await?;
476
477 let actual = hex::encode(hasher.lock().unwrap().clone().finalize());
478 if actual != hash {
479 let _ = self.storage.delete(&key).await;
480 return Err(DeployError::HashMismatch {
481 expected: hash.to_string(),
482 actual,
483 });
484 }
485 Ok(())
486 }
487
488 pub async fn open_blob(&self, hash: &str) -> Result<GetObject, DeployError> {
490 Ok(self.storage.get(&keys::blob(hash)).await?)
491 }
492
493 pub async fn open_blob_cached(&self, hash: &str, size: u64) -> Result<BlobBody, DeployError> {
500 use futures::TryStreamExt;
501 if size > SMALL_BLOB_CACHE_MAX {
502 return Ok(BlobBody::Stream(self.open_blob(hash).await?));
503 }
504 if let Some(bytes) = self.blob_body_cache.read().unwrap().map.get(hash).cloned() {
505 return Ok(BlobBody::Cached(bytes));
506 }
507 let object = self.storage.get(&keys::blob(hash)).await?;
510 let mut buf = bytes::BytesMut::with_capacity(size as usize);
511 let mut body = object.body;
512 while let Some(chunk) = body.try_next().await? {
513 buf.extend_from_slice(&chunk);
514 }
515 let bytes = buf.freeze();
516 {
517 let mut cache = self.blob_body_cache.write().unwrap();
518 if cache.bytes.saturating_add(bytes.len()) > BLOB_BODY_CACHE_MAX_BYTES {
520 cache.map.clear();
521 cache.bytes = 0;
522 }
523 if cache.map.insert(hash.to_string(), bytes.clone()).is_none() {
524 cache.bytes = cache.bytes.saturating_add(bytes.len());
525 }
526 }
527 Ok(BlobBody::Cached(bytes))
528 }
529
530 pub async fn open_blob_range(
532 &self,
533 hash: &str,
534 offset: u64,
535 len: Option<u64>,
536 ) -> Result<GetObject, DeployError> {
537 Ok(self
538 .storage
539 .get_range(&keys::blob(hash), offset, len)
540 .await?)
541 }
542
543 pub async fn get_site_config(
546 &self,
547 project: ProjectRef<'_>,
548 site: &str,
549 ) -> Result<Option<SiteConfig>, DeployError> {
550 let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
551 return Ok(None);
552 };
553 let hash = String::from_utf8_lossy(&hash).into_owned();
554 match self.kv.get(&keys::site_config_blob(&hash)).await? {
555 Some(bytes) => Ok(Some(SiteConfig::from_json(&bytes)?)),
556 None => Ok(None),
558 }
559 }
560
561 pub async fn get_site_config_cached(
568 &self,
569 project: ProjectRef<'_>,
570 site: &str,
571 ) -> Result<Option<Arc<SiteConfig>>, DeployError> {
572 let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
573 return Ok(None);
574 };
575 let hash = String::from_utf8_lossy(&hash).into_owned();
576 if let Some(cfg) = self.site_config_cache.read().unwrap().get(&hash).cloned() {
577 return Ok(Some(cfg));
578 }
579 let Some(bytes) = self.kv.get(&keys::site_config_blob(&hash)).await? else {
580 return Ok(None); };
582 let cfg = Arc::new(SiteConfig::from_json(&bytes)?);
583 {
584 let mut cache = self.site_config_cache.write().unwrap();
585 if cache.len() >= 512 {
589 cache.clear();
590 }
591 cache.insert(hash, Arc::clone(&cfg));
592 }
593 Ok(Some(cfg))
594 }
595
596 pub async fn set_site_config(
616 &self,
617 project: ProjectRef<'_>,
618 site: &str,
619 config: &SiteConfig,
620 ) -> Result<(), DeployError> {
621 let _claim = self.domain_claim_lock.lock().await;
622 self.set_site_config_locked(project, site, config).await
623 }
624
625 async fn set_site_config_locked(
630 &self,
631 project: ProjectRef<'_>,
632 site: &str,
633 config: &SiteConfig,
634 ) -> Result<(), DeployError> {
635 let owner = DomainOwner::new(project.as_str(), site);
636 for host in config.domains.exact_hosts() {
640 self.ensure_host_claimable(&keys::domain(host), host, &owner)
641 .await?;
642 }
643 for wildcard in &config.domains.wildcards {
644 if let Some(suffix) = wildcard.strip_prefix("*.") {
645 self.ensure_host_claimable(&keys::wildcard(suffix), wildcard, &owner)
646 .await?;
647 }
648 }
649
650 let body = config.to_json()?;
651 let hash = sha256_hex(&body);
652
653 let mut ops = Vec::new();
654 if let Some(old) = self.get_site_config(project, site).await? {
655 for host in old.domains.exact_hosts() {
656 ops.push(WriteOp::Delete(keys::domain(host)));
657 }
658 for wildcard in &old.domains.wildcards {
659 if let Some(suffix) = wildcard.strip_prefix("*.") {
660 ops.push(WriteOp::Delete(keys::wildcard(suffix)));
661 }
662 }
663 }
664
665 ops.push(WriteOp::Put(keys::site_config_blob(&hash), body));
667 ops.push(WriteOp::Put(
668 keys::site_pointer(project, site),
669 hash.into_bytes(),
670 ));
671
672 let owner_bytes = owner.to_bytes();
675 for host in config.domains.exact_hosts() {
676 ops.push(WriteOp::Put(keys::domain(host), owner_bytes.clone()));
677 }
678 for wildcard in &config.domains.wildcards {
679 if let Some(suffix) = wildcard.strip_prefix("*.") {
680 ops.push(WriteOp::Put(keys::wildcard(suffix), owner_bytes.clone()));
681 }
682 }
683 self.kv.write_batch(ops).await?;
684 Ok(())
685 }
686
687 async fn ensure_host_claimable(
693 &self,
694 key: &str,
695 label: &str,
696 owner: &DomainOwner,
697 ) -> Result<(), DeployError> {
698 if let Some(bytes) = self.kv.get(key).await? {
699 let held = DomainOwner::from_bytes(&bytes);
700 if &held != owner {
701 return Err(DeployError::Conflict(format!(
702 "{label} is already attached to site `{}` in project `{}`",
703 held.site, held.project
704 )));
705 }
706 }
707 Ok(())
708 }
709
710 pub async fn resolve_site_by_host(
715 &self,
716 host: &str,
717 ) -> Result<Option<DomainOwner>, DeployError> {
718 let host = canon_host(host);
721 let host = host.as_str();
722 if let Some(bytes) = self.kv.get(&keys::domain(host)).await? {
723 return Ok(Some(DomainOwner::from_bytes(&bytes)));
724 }
725 let mut rest = host;
726 while let Some((_, parent)) = rest.split_once('.') {
727 if let Some(bytes) = self.kv.get(&keys::wildcard(parent)).await? {
728 return Ok(Some(DomainOwner::from_bytes(&bytes)));
729 }
730 rest = parent;
731 }
732 Ok(None)
733 }
734
735 pub async fn all_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
741 let mut sites = BTreeSet::new();
742 for prefix in [
743 keys::current_prefix(project),
744 keys::site_prefix(project),
745 keys::history_prefix(project),
746 ] {
747 for key in self.kv.list_prefix(&prefix).await? {
748 if let Some(name) = key.strip_prefix(&prefix) {
749 if !name.is_empty() {
750 sites.insert(name.to_string());
751 }
752 }
753 }
754 }
755 Ok(sites.into_iter().collect())
756 }
757
758 pub async fn all_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
762 let mut out = Vec::new();
763 for project in self.discover_projects().await? {
764 for site in self.all_sites(ProjectRef::new(&project)).await? {
765 out.push((project.clone(), site));
766 }
767 }
768 Ok(out)
769 }
770
771 pub async fn discover_projects(&self) -> Result<Vec<String>, DeployError> {
776 let mut projects = BTreeSet::new();
777 for key in self.kv.list_prefix(keys::PROJECT_ROOT).await? {
778 if let Some(p) = keys::project_of_key(&key) {
779 projects.insert(p.to_string());
780 }
781 }
782 Ok(projects.into_iter().collect())
783 }
784
785 pub async fn put_function(
793 &self,
794 project: ProjectRef<'_>,
795 f: &crate::function::Function,
796 ) -> Result<(), DeployError> {
797 let bytes = serde_json::to_vec(f).map_err(|e| DeployError::Serde(e.to_string()))?;
798 self.kv
799 .put(
800 &crate::function::keys::meta(project.as_str(), &f.name),
801 bytes,
802 )
803 .await?;
804 Ok(())
805 }
806
807 pub async fn get_function(
809 &self,
810 project: ProjectRef<'_>,
811 name: &str,
812 ) -> Result<Option<crate::function::Function>, DeployError> {
813 match self
814 .kv
815 .get(&crate::function::keys::meta(project.as_str(), name))
816 .await?
817 {
818 Some(bytes) => Ok(Some(
819 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
820 )),
821 None => Ok(None),
822 }
823 }
824
825 pub async fn list_stored_functions(
831 &self,
832 project: ProjectRef<'_>,
833 ) -> Result<Vec<crate::function::Function>, DeployError> {
834 let prefix = crate::function::keys::functions_prefix(project.as_str());
835 let mut out = Vec::new();
836 for key in self.kv.list_prefix(&prefix).await? {
837 if key[prefix.len()..].contains('/') {
838 continue;
839 }
840 if let Some(bytes) = self.kv.get(&key).await? {
841 if let Ok(f) = serde_json::from_slice(&bytes) {
842 out.push(f);
843 }
844 }
845 }
846 Ok(out)
847 }
848
849 pub async fn delete_function(
852 &self,
853 project: ProjectRef<'_>,
854 name: &str,
855 ) -> Result<bool, DeployError> {
856 let key = crate::function::keys::meta(project.as_str(), name);
857 let existed = self.kv.get(&key).await?.is_some();
858 self.kv.delete(&key).await?;
859 Ok(existed)
860 }
861
862 pub async fn put_trigger(
866 &self,
867 project: ProjectRef<'_>,
868 function: &str,
869 trigger: &crate::function::FunctionTrigger,
870 ) -> Result<(), DeployError> {
871 let bytes = serde_json::to_vec(trigger).map_err(|e| DeployError::Serde(e.to_string()))?;
872 self.kv
873 .put(
874 &crate::function::keys::trigger(project.as_str(), function, &trigger.id),
875 bytes,
876 )
877 .await?;
878 Ok(())
879 }
880
881 pub async fn get_trigger(
883 &self,
884 project: ProjectRef<'_>,
885 function: &str,
886 id: &str,
887 ) -> Result<Option<crate::function::FunctionTrigger>, DeployError> {
888 match self
889 .kv
890 .get(&crate::function::keys::trigger(
891 project.as_str(),
892 function,
893 id,
894 ))
895 .await?
896 {
897 Some(bytes) => Ok(Some(
898 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
899 )),
900 None => Ok(None),
901 }
902 }
903
904 pub async fn list_triggers(
906 &self,
907 project: ProjectRef<'_>,
908 function: &str,
909 ) -> Result<Vec<crate::function::FunctionTrigger>, DeployError> {
910 let prefix = crate::function::keys::triggers_prefix(project.as_str(), function);
911 let mut out = Vec::new();
912 for key in self.kv.list_prefix(&prefix).await? {
913 if let Some(bytes) = self.kv.get(&key).await? {
914 if let Ok(t) = serde_json::from_slice(&bytes) {
915 out.push(t);
916 }
917 }
918 }
919 Ok(out)
920 }
921
922 pub async fn delete_trigger(
924 &self,
925 project: ProjectRef<'_>,
926 function: &str,
927 id: &str,
928 ) -> Result<bool, DeployError> {
929 let key = crate::function::keys::trigger(project.as_str(), function, id);
930 let existed = self.kv.get(&key).await?.is_some();
931 self.kv.delete(&key).await?;
932 Ok(existed)
933 }
934
935 pub async fn put_invocation(
939 &self,
940 project: ProjectRef<'_>,
941 inv: &crate::function::Invocation,
942 ) -> Result<(), DeployError> {
943 let bytes = serde_json::to_vec(inv).map_err(|e| DeployError::Serde(e.to_string()))?;
944 self.kv
945 .put(
946 &crate::function::keys::invocation(project.as_str(), &inv.function, &inv.id),
947 bytes,
948 )
949 .await?;
950 Ok(())
951 }
952
953 pub async fn get_invocation(
955 &self,
956 project: ProjectRef<'_>,
957 function: &str,
958 id: &str,
959 ) -> Result<Option<crate::function::Invocation>, DeployError> {
960 match self
961 .kv
962 .get(&crate::function::keys::invocation(
963 project.as_str(),
964 function,
965 id,
966 ))
967 .await?
968 {
969 Some(bytes) => Ok(Some(
970 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
971 )),
972 None => Ok(None),
973 }
974 }
975
976 pub async fn list_invocations(
978 &self,
979 project: ProjectRef<'_>,
980 function: &str,
981 ) -> Result<Vec<crate::function::Invocation>, DeployError> {
982 let prefix = crate::function::keys::invocations_prefix(project.as_str(), function);
983 let mut out = Vec::new();
984 for key in self.kv.list_prefix(&prefix).await? {
985 if let Some(bytes) = self.kv.get(&key).await? {
986 if let Ok(inv) = serde_json::from_slice(&bytes) {
987 out.push(inv);
988 }
989 }
990 }
991 Ok(out)
992 }
993
994 pub async fn put_idempotency(
997 &self,
998 project: ProjectRef<'_>,
999 function: &str,
1000 key: &str,
1001 invocation_id: &str,
1002 ) -> Result<(), DeployError> {
1003 self.kv
1004 .put(
1005 &crate::function::keys::idempotency(project.as_str(), function, key),
1006 invocation_id.as_bytes().to_vec(),
1007 )
1008 .await?;
1009 Ok(())
1010 }
1011
1012 pub async fn get_idempotency(
1014 &self,
1015 project: ProjectRef<'_>,
1016 function: &str,
1017 key: &str,
1018 ) -> Result<Option<String>, DeployError> {
1019 match self
1020 .kv
1021 .get(&crate::function::keys::idempotency(
1022 project.as_str(),
1023 function,
1024 key,
1025 ))
1026 .await?
1027 {
1028 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1029 None => Ok(None),
1030 }
1031 }
1032
1033 pub async fn get_metering(
1037 &self,
1038 project: ProjectRef<'_>,
1039 function: &str,
1040 ) -> Result<Option<crate::function::Metering>, DeployError> {
1041 match self
1042 .kv
1043 .get(&crate::function::keys::metering(project.as_str(), function))
1044 .await?
1045 {
1046 Some(bytes) => Ok(Some(
1047 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1048 )),
1049 None => Ok(None),
1050 }
1051 }
1052
1053 pub async fn put_metering(
1055 &self,
1056 project: ProjectRef<'_>,
1057 metering: &crate::function::Metering,
1058 ) -> Result<(), DeployError> {
1059 let bytes = serde_json::to_vec(metering).map_err(|e| DeployError::Serde(e.to_string()))?;
1060 self.kv
1061 .put(
1062 &crate::function::keys::metering(project.as_str(), &metering.function),
1063 bytes,
1064 )
1065 .await?;
1066 Ok(())
1067 }
1068
1069 pub async fn list_metering(
1072 &self,
1073 project: ProjectRef<'_>,
1074 ) -> Result<Vec<crate::function::Metering>, DeployError> {
1075 let prefix = crate::function::keys::metering_prefix(project.as_str());
1076 let mut out = Vec::new();
1077 for key in self.kv.list_prefix(&prefix).await? {
1078 if let Some(bytes) = self.kv.get(&key).await? {
1079 if let Ok(m) = serde_json::from_slice(&bytes) {
1080 out.push(m);
1081 }
1082 }
1083 }
1084 Ok(out)
1085 }
1086
1087 pub async fn put_managed_notification(
1092 &self,
1093 project: ProjectRef<'_>,
1094 record: &crate::blob_notify::ManagedNotification,
1095 ) -> Result<(), DeployError> {
1096 let bytes = record
1097 .to_json()
1098 .map_err(|e| DeployError::Serde(e.to_string()))?;
1099 self.kv
1100 .put(
1101 &crate::blob_notify::blobnotify_key(
1102 project.as_str(),
1103 &record.function,
1104 &record.prefix,
1105 ),
1106 bytes,
1107 )
1108 .await?;
1109 Ok(())
1110 }
1111
1112 pub async fn get_managed_notification(
1114 &self,
1115 project: ProjectRef<'_>,
1116 function: &str,
1117 prefix: &str,
1118 ) -> Result<Option<crate::blob_notify::ManagedNotification>, DeployError> {
1119 match self
1120 .kv
1121 .get(&crate::blob_notify::blobnotify_key(
1122 project.as_str(),
1123 function,
1124 prefix,
1125 ))
1126 .await?
1127 {
1128 Some(bytes) => Ok(Some(
1129 crate::blob_notify::ManagedNotification::from_json(&bytes)
1130 .map_err(|e| DeployError::Serde(e.to_string()))?,
1131 )),
1132 None => Ok(None),
1133 }
1134 }
1135
1136 pub async fn list_managed_notifications(
1138 &self,
1139 project: ProjectRef<'_>,
1140 function: &str,
1141 ) -> Result<Vec<crate::blob_notify::ManagedNotification>, DeployError> {
1142 let prefix = crate::blob_notify::blobnotify_function_prefix(project.as_str(), function);
1143 let mut out = Vec::new();
1144 for key in self.kv.list_prefix(&prefix).await? {
1145 if let Some(bytes) = self.kv.get(&key).await? {
1146 if let Ok(record) = crate::blob_notify::ManagedNotification::from_json(&bytes) {
1147 out.push(record);
1148 }
1149 }
1150 }
1151 Ok(out)
1152 }
1153
1154 pub async fn remove_managed_notification(
1156 &self,
1157 project: ProjectRef<'_>,
1158 function: &str,
1159 prefix: &str,
1160 ) -> Result<(), DeployError> {
1161 self.kv
1162 .delete(&crate::blob_notify::blobnotify_key(
1163 project.as_str(),
1164 function,
1165 prefix,
1166 ))
1167 .await?;
1168 Ok(())
1169 }
1170
1171 pub async fn put_workflow(
1175 &self,
1176 project: ProjectRef<'_>,
1177 workflow: &crate::workflow::Workflow,
1178 ) -> Result<(), DeployError> {
1179 let bytes = serde_json::to_vec(workflow).map_err(|e| DeployError::Serde(e.to_string()))?;
1180 self.kv
1181 .put(
1182 &crate::workflow::keys::definition(project.as_str(), &workflow.name),
1183 bytes,
1184 )
1185 .await?;
1186 Ok(())
1187 }
1188
1189 pub async fn get_workflow(
1191 &self,
1192 project: ProjectRef<'_>,
1193 name: &str,
1194 ) -> Result<Option<crate::workflow::Workflow>, DeployError> {
1195 match self
1196 .kv
1197 .get(&crate::workflow::keys::definition(project.as_str(), name))
1198 .await?
1199 {
1200 Some(bytes) => Ok(Some(
1201 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1202 )),
1203 None => Ok(None),
1204 }
1205 }
1206
1207 pub async fn list_workflows(
1211 &self,
1212 project: ProjectRef<'_>,
1213 ) -> Result<Vec<crate::workflow::Workflow>, DeployError> {
1214 let prefix = crate::workflow::keys::definitions_prefix(project.as_str());
1215 let mut out = Vec::new();
1216 for key in self.kv.list_prefix(&prefix).await? {
1217 if key[prefix.len()..].contains('/') {
1218 continue;
1219 }
1220 if let Some(bytes) = self.kv.get(&key).await? {
1221 if let Ok(w) = serde_json::from_slice(&bytes) {
1222 out.push(w);
1223 }
1224 }
1225 }
1226 Ok(out)
1227 }
1228
1229 pub async fn delete_workflow(
1232 &self,
1233 project: ProjectRef<'_>,
1234 name: &str,
1235 ) -> Result<bool, DeployError> {
1236 let key = crate::workflow::keys::definition(project.as_str(), name);
1237 let existed = self.kv.get(&key).await?.is_some();
1238 self.kv.delete(&key).await?;
1239 Ok(existed)
1240 }
1241
1242 pub async fn put_workflow_run(
1244 &self,
1245 project: ProjectRef<'_>,
1246 run: &crate::workflow::WorkflowRun,
1247 ) -> Result<(), DeployError> {
1248 let bytes = serde_json::to_vec(run).map_err(|e| DeployError::Serde(e.to_string()))?;
1249 self.kv
1250 .put(
1251 &crate::workflow::keys::run(project.as_str(), &run.workflow, &run.id),
1252 bytes,
1253 )
1254 .await?;
1255 Ok(())
1256 }
1257
1258 pub async fn get_workflow_run(
1260 &self,
1261 project: ProjectRef<'_>,
1262 workflow: &str,
1263 id: &str,
1264 ) -> Result<Option<crate::workflow::WorkflowRun>, DeployError> {
1265 match self
1266 .kv
1267 .get(&crate::workflow::keys::run(project.as_str(), workflow, id))
1268 .await?
1269 {
1270 Some(bytes) => Ok(Some(
1271 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1272 )),
1273 None => Ok(None),
1274 }
1275 }
1276
1277 pub async fn list_workflow_runs(
1279 &self,
1280 project: ProjectRef<'_>,
1281 workflow: &str,
1282 ) -> Result<Vec<crate::workflow::WorkflowRun>, DeployError> {
1283 let prefix = crate::workflow::keys::runs_prefix(project.as_str(), workflow);
1284 let mut out = Vec::new();
1285 for key in self.kv.list_prefix(&prefix).await? {
1286 if let Some(bytes) = self.kv.get(&key).await? {
1287 if let Ok(run) = serde_json::from_slice(&bytes) {
1288 out.push(run);
1289 }
1290 }
1291 }
1292 Ok(out)
1293 }
1294
1295 pub async fn get_domain_verification(
1297 &self,
1298 project: ProjectRef<'_>,
1299 site: &SiteName,
1300 host: &str,
1301 ) -> Result<Option<DomainVerification>, DeployError> {
1302 match self
1303 .kv
1304 .get(&keys::domain_verification(project, site.as_str(), host))
1305 .await?
1306 {
1307 Some(bytes) => Ok(Some(DomainVerification::from_json(&bytes)?)),
1308 None => Ok(None),
1309 }
1310 }
1311
1312 pub async fn list_domain_verifications(
1314 &self,
1315 project: ProjectRef<'_>,
1316 site: &SiteName,
1317 ) -> Result<Vec<DomainVerification>, DeployError> {
1318 let prefix = keys::domain_verification_prefix(project, site.as_str());
1319 let mut out = Vec::new();
1320 for key in self.kv.list_prefix(&prefix).await? {
1321 if let Some(bytes) = self.kv.get(&key).await? {
1322 out.push(DomainVerification::from_json(&bytes)?);
1323 }
1324 }
1325 out.sort_by(|a, b| a.host.cmp(&b.host));
1326 Ok(out)
1327 }
1328
1329 pub async fn list_all_domain_verifications(
1336 &self,
1337 ) -> Result<Vec<(String, String, DomainVerification)>, DeployError> {
1338 let mut out = Vec::new();
1339 for project in self.discover_projects().await? {
1340 let pref = ProjectRef::new(&project);
1341 let scan = keys::domain_verification_project_prefix(pref);
1342 for key in self.kv.list_prefix(&scan).await? {
1343 let Some((site, _host)) = key
1346 .strip_prefix(&scan)
1347 .and_then(|rest| rest.split_once('/'))
1348 else {
1349 continue;
1350 };
1351 if let Some(bytes) = self.kv.get(&key).await? {
1352 out.push((
1353 project.clone(),
1354 site.to_string(),
1355 DomainVerification::from_json(&bytes)?,
1356 ));
1357 }
1358 }
1359 }
1360 Ok(out)
1361 }
1362
1363 pub async fn find_pending_http_challenge(
1372 &self,
1373 host: &str,
1374 token: &str,
1375 now_unix: u64,
1376 ) -> Result<Option<DomainVerification>, DeployError> {
1377 let host = crate::domain_verify::normalize_host(host);
1378 let Some(owner_bytes) = self
1383 .kv
1384 .get(&keys::http_challenge_index(&host, token))
1385 .await?
1386 else {
1387 return Ok(None);
1388 };
1389 let owner = DomainOwner::from_bytes(&owner_bytes);
1390 let site = SiteName::new(owner.site);
1391 let Some(v) = self
1392 .get_domain_verification(ProjectRef::new(&owner.project), &site, &host)
1393 .await?
1394 else {
1395 return Ok(None);
1396 };
1397 if v.method == VerificationMethod::Http
1398 && v.host == host
1399 && v.matches(token)
1400 && !v.is_expired(now_unix)
1401 {
1402 Ok(Some(v))
1403 } else {
1404 Ok(None)
1405 }
1406 }
1407
1408 async fn put_domain_verification(
1409 &self,
1410 project: ProjectRef<'_>,
1411 site: &SiteName,
1412 verification: &DomainVerification,
1413 ) -> Result<(), DeployError> {
1414 let mut ops = vec![WriteOp::Put(
1415 keys::domain_verification(project, site.as_str(), &verification.host),
1416 verification.to_json()?,
1417 )];
1418 if verification.method == VerificationMethod::Http {
1424 ops.push(WriteOp::Put(
1425 keys::http_challenge_index(&verification.host, &verification.token),
1426 DomainOwner::new(project.as_str(), site.as_str()).to_bytes(),
1427 ));
1428 }
1429 self.kv.write_batch(ops).await?;
1430 Ok(())
1431 }
1432
1433 pub async fn get_managed_dns(
1437 &self,
1438 project: ProjectRef<'_>,
1439 site: &SiteName,
1440 host: &str,
1441 ) -> Result<Option<crate::dns_managed::ManagedDns>, DeployError> {
1442 match self
1443 .kv
1444 .get(&crate::dns_managed::dnsmanaged_key(
1445 project.as_str(),
1446 site.as_str(),
1447 host,
1448 ))
1449 .await?
1450 {
1451 Some(bytes) => Ok(Some(crate::dns_managed::ManagedDns::from_json(&bytes)?)),
1452 None => Ok(None),
1453 }
1454 }
1455
1456 pub async fn set_managed_dns(
1458 &self,
1459 project: ProjectRef<'_>,
1460 site: &SiteName,
1461 ledger: &crate::dns_managed::ManagedDns,
1462 ) -> Result<(), DeployError> {
1463 self.kv
1464 .put(
1465 &crate::dns_managed::dnsmanaged_key(project.as_str(), site.as_str(), &ledger.host),
1466 ledger.to_json()?,
1467 )
1468 .await?;
1469 Ok(())
1470 }
1471
1472 pub async fn remove_managed_dns(
1474 &self,
1475 project: ProjectRef<'_>,
1476 site: &SiteName,
1477 host: &str,
1478 ) -> Result<(), DeployError> {
1479 self.kv
1480 .delete(&crate::dns_managed::dnsmanaged_key(
1481 project.as_str(),
1482 site.as_str(),
1483 host,
1484 ))
1485 .await?;
1486 Ok(())
1487 }
1488
1489 pub async fn list_managed_dns(
1492 &self,
1493 project: ProjectRef<'_>,
1494 site: &SiteName,
1495 ) -> Result<Vec<crate::dns_managed::ManagedDns>, DeployError> {
1496 let prefix = crate::dns_managed::dnsmanaged_site_prefix(project.as_str(), site.as_str());
1497 let mut out = Vec::new();
1498 for key in self.kv.list_prefix(&prefix).await? {
1499 if let Some(bytes) = self.kv.get(&key).await? {
1500 out.push(crate::dns_managed::ManagedDns::from_json(&bytes)?);
1501 }
1502 }
1503 out.sort_by(|a, b| a.host.cmp(&b.host));
1504 Ok(out)
1505 }
1506
1507 pub async fn start_domain_verification(
1514 &self,
1515 project: ProjectRef<'_>,
1516 site: &SiteName,
1517 host: &str,
1518 method: VerificationMethod,
1519 now_unix: u64,
1520 ) -> Result<DomainVerification, DeployError> {
1521 if let Some(existing) = self.get_domain_verification(project, site, host).await? {
1522 if existing.verified || existing.method == method {
1523 return Ok(existing);
1524 }
1525 }
1526 const MAX_PENDING_VERIFICATIONS_PER_SITE: usize = 64;
1533 let pending = self
1534 .list_domain_verifications(project, site)
1535 .await?
1536 .into_iter()
1537 .filter(|v| !v.verified && !v.is_expired(now_unix))
1538 .count();
1539 if pending >= MAX_PENDING_VERIFICATIONS_PER_SITE {
1540 return Err(DeployError::Conflict(format!(
1541 "too many pending domain verifications for site {site} \
1542 (max {MAX_PENDING_VERIFICATIONS_PER_SITE}); verify or remove some first"
1543 )));
1544 }
1545 let verification = DomainVerification::new(host, method, now_unix);
1546 self.put_domain_verification(project, site, &verification)
1547 .await?;
1548 Ok(verification)
1549 }
1550
1551 pub async fn is_domain_verified(
1553 &self,
1554 project: ProjectRef<'_>,
1555 site: &SiteName,
1556 host: &str,
1557 ) -> Result<bool, DeployError> {
1558 Ok(self
1559 .get_domain_verification(project, site, host)
1560 .await?
1561 .is_some_and(|v| v.verified))
1562 }
1563
1564 pub async fn mark_domain_verified(
1567 &self,
1568 project: ProjectRef<'_>,
1569 site: &SiteName,
1570 host: &str,
1571 ) -> Result<DomainVerification, DeployError> {
1572 let mut verification = self
1573 .get_domain_verification(project, site, host)
1574 .await?
1575 .ok_or_else(|| {
1576 DeployError::NotFound(format!("no verification challenge for {host}"))
1577 })?;
1578 verification.verified = true;
1579 self.put_domain_verification(project, site, &verification)
1580 .await?;
1581 Ok(verification)
1582 }
1583
1584 pub async fn remove_domain_verification(
1587 &self,
1588 project: ProjectRef<'_>,
1589 site: &SiteName,
1590 host: &str,
1591 ) -> Result<bool, DeployError> {
1592 let Some(v) = self.get_domain_verification(project, site, host).await? else {
1593 return Ok(false);
1594 };
1595 let mut ops = vec![WriteOp::Delete(keys::domain_verification(
1596 project,
1597 site.as_str(),
1598 host,
1599 ))];
1600 if v.method == VerificationMethod::Http {
1601 ops.push(WriteOp::Delete(keys::http_challenge_index(
1602 &v.host, &v.token,
1603 )));
1604 }
1605 self.kv.write_batch(ops).await?;
1606 Ok(true)
1607 }
1608
1609 pub async fn attach_verified_domain(
1620 &self,
1621 project: ProjectRef<'_>,
1622 site: &SiteName,
1623 host: &str,
1624 ) -> Result<SiteConfig, DeployError> {
1625 if !self.is_domain_verified(project, site, host).await? {
1626 return Err(DeployError::NotFound(format!(
1627 "{host} is not verified for {site}; run domain verification first"
1628 )));
1629 }
1630 if host.trim_start().starts_with("*.")
1634 && self
1635 .get_domain_verification(project, site, host)
1636 .await?
1637 .map(|v| v.method)
1638 != Some(VerificationMethod::Dns)
1639 {
1640 return Err(DeployError::Conflict(format!(
1641 "wildcard {host} must be verified via DNS \
1642 (an HTTP token proves only the base host, not the subtree)"
1643 )));
1644 }
1645 let _claim = self.domain_claim_lock.lock().await;
1649 let mut config = self
1650 .get_site_config(project, site.as_str())
1651 .await?
1652 .unwrap_or_default();
1653 let domains = &mut config.domains;
1654 if let Some(suffix) = host.strip_prefix("*.") {
1655 let wildcard = format!("*.{}", suffix.trim_end_matches('.').to_ascii_lowercase());
1656 if !domains.wildcards.contains(&wildcard) {
1657 domains.wildcards.push(wildcard);
1658 }
1659 } else {
1660 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
1661 if domains.primary.is_none() {
1662 domains.primary = Some(host);
1663 } else if domains.primary.as_deref() != Some(host.as_str())
1664 && !domains.aliases.contains(&host)
1665 {
1666 domains.aliases.push(host);
1667 }
1668 }
1669 self.set_site_config_locked(project, site.as_str(), &config)
1670 .await?;
1671 Ok(config)
1672 }
1673
1674 pub async fn set_alias(
1680 &self,
1681 project: ProjectRef<'_>,
1682 site: &str,
1683 name: &str,
1684 id: &str,
1685 ) -> Result<(), DeployError> {
1686 let manifest = self
1687 .get_manifest(id)
1688 .await?
1689 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
1690 let missing = self.missing_blobs(&manifest).await?;
1691 if !missing.is_empty() {
1692 return Err(DeployError::Incomplete(missing));
1693 }
1694 self.kv
1695 .put(&keys::alias(project, site, name), id.as_bytes().to_vec())
1696 .await?;
1697 Ok(())
1698 }
1699
1700 pub async fn get_alias(
1702 &self,
1703 project: ProjectRef<'_>,
1704 site: &str,
1705 name: &str,
1706 ) -> Result<Option<String>, DeployError> {
1707 match self.kv.get(&keys::alias(project, site, name)).await? {
1708 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1709 None => Ok(None),
1710 }
1711 }
1712
1713 pub async fn remove_alias(
1715 &self,
1716 project: ProjectRef<'_>,
1717 site: &str,
1718 name: &str,
1719 ) -> Result<bool, DeployError> {
1720 let key = keys::alias(project, site, name);
1721 let existed = self.kv.get(&key).await?.is_some();
1722 if existed {
1723 self.kv.delete(&key).await?;
1724 }
1725 Ok(existed)
1726 }
1727
1728 pub async fn list_aliases(
1730 &self,
1731 project: ProjectRef<'_>,
1732 site: &str,
1733 ) -> Result<BTreeMap<String, String>, DeployError> {
1734 let prefix = keys::alias_prefix(project, site);
1735 let mut out = BTreeMap::new();
1736 for key in self.kv.list_prefix(&prefix).await? {
1737 if let Some(bytes) = self.kv.get(&key).await? {
1738 let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
1739 out.insert(name, String::from_utf8_lossy(&bytes).into_owned());
1740 }
1741 }
1742 Ok(out)
1743 }
1744
1745 pub async fn put_token_meta(&self, meta: &crate::authz::TokenMeta) -> Result<(), DeployError> {
1750 self.kv
1751 .put(
1752 &crate::authz::token_meta_key(&meta.revocation_id),
1753 serde_json::to_vec(meta)?,
1754 )
1755 .await?;
1756 Ok(())
1757 }
1758
1759 pub async fn list_token_meta(&self) -> Result<Vec<crate::authz::TokenMeta>, DeployError> {
1761 let mut out = Vec::new();
1762 for key in self.kv.list_prefix(crate::authz::TOKEN_META_PREFIX).await? {
1763 if let Some(bytes) = self.kv.get(&key).await? {
1764 if let Ok(meta) = serde_json::from_slice::<crate::authz::TokenMeta>(&bytes) {
1765 out.push(meta);
1766 }
1767 }
1768 }
1769 Ok(out)
1770 }
1771
1772 pub async fn revoke_token(&self, id_or_prefix: &str) -> Result<bool, DeployError> {
1777 let ids: Vec<String> = self
1778 .list_token_meta()
1779 .await?
1780 .into_iter()
1781 .map(|m| m.revocation_id)
1782 .collect();
1783 let matches: Vec<&String> = ids
1784 .iter()
1785 .filter(|id| id.starts_with(id_or_prefix))
1786 .collect();
1787 if let [id] = matches.as_slice() {
1788 self.kv
1789 .put(&crate::authz::revoked_key(id), Vec::new())
1790 .await?;
1791 self.kv.delete(&crate::authz::token_meta_key(id)).await?;
1792 Ok(true)
1793 } else {
1794 Ok(false)
1795 }
1796 }
1797
1798 pub async fn bootstrap_consumed(&self, secret_hash: &str) -> Result<bool, DeployError> {
1803 Ok(self
1804 .kv
1805 .get(&crate::authz::bootstrap_key(secret_hash))
1806 .await?
1807 .is_some())
1808 }
1809
1810 pub async fn mark_bootstrap_consumed(&self, secret_hash: &str) -> Result<(), DeployError> {
1812 self.kv
1813 .put(&crate::authz::bootstrap_key(secret_hash), Vec::new())
1814 .await?;
1815 Ok(())
1816 }
1817
1818 pub async fn get_authz_policy(&self) -> Result<Option<crate::authz::AuthzPolicy>, DeployError> {
1821 match self.kv.get(crate::authz::POLICY_KEY).await? {
1822 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1823 None => Ok(None),
1824 }
1825 }
1826
1827 pub async fn set_authz_policy(
1831 &self,
1832 policy: &crate::authz::AuthzPolicy,
1833 ) -> Result<(), DeployError> {
1834 self.kv
1835 .put(crate::authz::POLICY_KEY, serde_json::to_vec(policy)?)
1836 .await?;
1837 Ok(())
1838 }
1839
1840 pub async fn add_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1845 self.kv
1846 .put(&crate::authz::root_anchor_key(pubkey), Vec::new())
1847 .await?;
1848 Ok(())
1849 }
1850
1851 pub async fn remove_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
1853 self.kv
1854 .delete(&crate::authz::root_anchor_key(pubkey))
1855 .await?;
1856 Ok(())
1857 }
1858
1859 pub async fn list_root_anchors(&self) -> Result<Vec<String>, DeployError> {
1861 Ok(self
1862 .kv
1863 .list_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1864 .await?
1865 .iter()
1866 .filter_map(|k| {
1867 k.strip_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
1868 .map(String::from)
1869 })
1870 .collect())
1871 }
1872
1873 const DAEMON_CURRENT_KEY: &'static str = "daemon/current";
1877 const DAEMON_HISTORY_KEY: &'static str = "daemon/history";
1879 const DAEMON_HISTORY_MAX: usize = 20;
1881
1882 pub async fn daemon_config_generation(&self) -> Result<Option<String>, DeployError> {
1885 Ok(self
1886 .kv
1887 .get(Self::DAEMON_CURRENT_KEY)
1888 .await?
1889 .map(|b| String::from_utf8_lossy(&b).into_owned()))
1890 }
1891
1892 pub async fn get_daemon_config(
1895 &self,
1896 ) -> Result<Option<crate::daemon_config::DaemonConfig>, DeployError> {
1897 let Some(hash) = self.daemon_config_generation().await? else {
1898 return Ok(None);
1899 };
1900 match self.kv.get(&keys::daemon_config_blob(&hash)).await? {
1901 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1902 None => Ok(None),
1904 }
1905 }
1906
1907 pub async fn daemon_config_history(&self) -> Result<Vec<String>, DeployError> {
1910 match self.kv.get(Self::DAEMON_HISTORY_KEY).await? {
1911 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
1912 None => Ok(Vec::new()),
1913 }
1914 }
1915
1916 pub async fn set_daemon_config(
1922 &self,
1923 config: &crate::daemon_config::DaemonConfig,
1924 ) -> Result<String, DeployError> {
1925 let body = serde_json::to_vec(config)?;
1926 let hash = sha256_hex(&body);
1927 let mut history = self.daemon_config_history().await?;
1928 if let Some(current) = self.daemon_config_generation().await? {
1929 if current != hash {
1930 history.push(current);
1931 if history.len() > Self::DAEMON_HISTORY_MAX {
1932 let overflow = history.len() - Self::DAEMON_HISTORY_MAX;
1933 history.drain(0..overflow);
1934 }
1935 }
1936 }
1937 let ops = vec![
1938 WriteOp::Put(keys::daemon_config_blob(&hash), body),
1939 WriteOp::Put(
1940 Self::DAEMON_HISTORY_KEY.to_string(),
1941 serde_json::to_vec(&history)?,
1942 ),
1943 WriteOp::Put(
1944 Self::DAEMON_CURRENT_KEY.to_string(),
1945 hash.clone().into_bytes(),
1946 ),
1947 ];
1948 self.kv.write_batch(ops).await?;
1949 Ok(hash)
1950 }
1951
1952 pub async fn rollback_daemon_config(&self) -> Result<Option<String>, DeployError> {
1957 let mut history = self.daemon_config_history().await?;
1958 let Some(prev) = history.pop() else {
1959 return Ok(None);
1960 };
1961 let ops = vec![
1962 WriteOp::Put(
1963 Self::DAEMON_HISTORY_KEY.to_string(),
1964 serde_json::to_vec(&history)?,
1965 ),
1966 WriteOp::Put(
1967 Self::DAEMON_CURRENT_KEY.to_string(),
1968 prev.clone().into_bytes(),
1969 ),
1970 ];
1971 self.kv.write_batch(ops).await?;
1972 Ok(Some(prev))
1973 }
1974
1975 pub async fn put_compute_spec(
1980 &self,
1981 spec: &crate::compute::ComputeSpec,
1982 ) -> Result<String, DeployError> {
1983 let id = spec.id();
1984 self.kv
1985 .put(&crate::compute::spec_key(&id), serde_json::to_vec(spec)?)
1986 .await?;
1987 Ok(id)
1988 }
1989
1990 pub async fn get_compute_spec(
1992 &self,
1993 hash: &str,
1994 ) -> Result<Option<crate::compute::ComputeSpec>, DeployError> {
1995 match self.kv.get(&crate::compute::spec_key(hash)).await? {
1996 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
1997 None => Ok(None),
1998 }
1999 }
2000
2001 pub async fn set_compute_workload(
2005 &self,
2006 project: ProjectRef<'_>,
2007 workload: &crate::compute::ComputeWorkload,
2008 ) -> Result<(), DeployError> {
2009 self.kv
2010 .put(
2011 &crate::compute::workload_key(project.as_str(), &workload.name),
2012 serde_json::to_vec(workload)?,
2013 )
2014 .await?;
2015 Ok(())
2016 }
2017
2018 pub async fn get_compute_workload(
2020 &self,
2021 project: ProjectRef<'_>,
2022 name: &str,
2023 ) -> Result<Option<crate::compute::ComputeWorkload>, DeployError> {
2024 match self
2025 .kv
2026 .get(&crate::compute::workload_key(project.as_str(), name))
2027 .await?
2028 {
2029 Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2030 None => Ok(None),
2031 }
2032 }
2033
2034 pub async fn list_compute_workloads(
2036 &self,
2037 project: ProjectRef<'_>,
2038 ) -> Result<Vec<crate::compute::ComputeWorkload>, DeployError> {
2039 let prefix = crate::compute::workloads_prefix(project.as_str());
2040 let mut out = Vec::new();
2041 for key in self.kv.list_prefix(&prefix).await? {
2042 if let Some(bytes) = self.kv.get(&key).await? {
2043 if let Ok(w) = serde_json::from_slice::<crate::compute::ComputeWorkload>(&bytes) {
2044 out.push(w);
2045 }
2046 }
2047 }
2048 Ok(out)
2049 }
2050
2051 pub async fn list_compute_workloads_all(
2054 &self,
2055 ) -> Result<Vec<(String, crate::compute::ComputeWorkload)>, DeployError> {
2056 let mut out = Vec::new();
2057 for project in self.discover_projects().await? {
2058 for w in self
2059 .list_compute_workloads(ProjectRef::new(&project))
2060 .await?
2061 {
2062 out.push((project.clone(), w));
2063 }
2064 }
2065 Ok(out)
2066 }
2067
2068 pub async fn delete_compute_workload(
2071 &self,
2072 project: ProjectRef<'_>,
2073 name: &str,
2074 ) -> Result<bool, DeployError> {
2075 let key = crate::compute::workload_key(project.as_str(), name);
2076 let existed = self.kv.get(&key).await?.is_some();
2077 if existed {
2078 self.kv.delete(&key).await?;
2079 }
2080 Ok(existed)
2081 }
2082
2083 pub async fn set_replica_state(
2087 &self,
2088 project: ProjectRef<'_>,
2089 state: &crate::compute::ObservedInstance,
2090 ) -> Result<(), DeployError> {
2091 self.kv
2092 .put(
2093 &crate::compute::replica_state_key(
2094 project.as_str(),
2095 &state.handle.workload,
2096 state.handle.replica,
2097 ),
2098 serde_json::to_vec(state)?,
2099 )
2100 .await?;
2101 Ok(())
2102 }
2103
2104 pub async fn list_replica_states(
2106 &self,
2107 project: ProjectRef<'_>,
2108 workload: &str,
2109 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2110 let mut out = Vec::new();
2111 for key in self
2112 .kv
2113 .list_prefix(&crate::compute::replica_state_prefix(
2114 project.as_str(),
2115 workload,
2116 ))
2117 .await?
2118 {
2119 if let Some(bytes) = self.kv.get(&key).await? {
2120 if let Ok(state) =
2121 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2122 {
2123 out.push(state);
2124 }
2125 }
2126 }
2127 Ok(out)
2128 }
2129
2130 pub async fn list_all_replica_states(
2134 &self,
2135 ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2136 let mut out = Vec::new();
2137 for project in self.discover_projects().await? {
2138 let prefix = crate::compute::replica_states_project_prefix(&project);
2139 for key in self.kv.list_prefix(&prefix).await? {
2140 if let Some(bytes) = self.kv.get(&key).await? {
2141 if let Ok(state) =
2142 serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2143 {
2144 out.push(state);
2145 }
2146 }
2147 }
2148 }
2149 Ok(out)
2150 }
2151
2152 pub async fn delete_replica_state(
2154 &self,
2155 project: ProjectRef<'_>,
2156 workload: &str,
2157 replica: u32,
2158 ) -> Result<(), DeployError> {
2159 self.kv
2160 .delete(&crate::compute::replica_state_key(
2161 project.as_str(),
2162 workload,
2163 replica,
2164 ))
2165 .await?;
2166 Ok(())
2167 }
2168
2169 pub async fn put_project(&self, p: &crate::project::Project) -> Result<String, DeployError> {
2181 let hash = p.id();
2182 let body = serde_json::to_vec(p).map_err(|e| DeployError::Serde(e.to_string()))?;
2183 let pointer = crate::project::pointer_key(&p.name);
2184 let mut history: Vec<String> =
2187 match self.kv.get(&crate::project::history_key(&p.name)).await? {
2188 Some(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
2189 None => Vec::new(),
2190 };
2191 if let Some(current) = self.kv.get(&pointer).await? {
2192 let current = String::from_utf8_lossy(¤t).into_owned();
2193 if current != hash {
2194 history.retain(|h| h != ¤t);
2195 history.insert(0, current);
2196 history.truncate(MAX_HISTORY);
2197 }
2198 }
2199 self.kv
2200 .write_batch(vec![
2201 WriteOp::Put(crate::project::spec_key(&hash), body),
2202 WriteOp::Put(pointer, hash.clone().into_bytes()),
2203 WriteOp::Put(
2204 crate::project::history_key(&p.name),
2205 serde_json::to_vec(&history).map_err(|e| DeployError::Serde(e.to_string()))?,
2206 ),
2207 ])
2208 .await?;
2209 Ok(hash)
2210 }
2211
2212 pub fn default_project_record() -> crate::project::Project {
2217 crate::project::Project {
2218 version: crate::SCHEMA_VERSION,
2219 name: crate::project::DEFAULT_PROJECT.to_string(),
2220 created_at: now_unix(),
2221 meta: crate::project::ProjectMeta::default(),
2222 config: crate::project::ProjectConfig::default(),
2223 secrets_ref: None,
2224 }
2225 }
2226
2227 pub async fn ensure_default_project(&self) -> Result<bool, DeployError> {
2235 let pointer = crate::project::pointer_key(crate::project::DEFAULT_PROJECT);
2236 if self.kv.get(&pointer).await?.is_some() {
2237 return Ok(false);
2238 }
2239 let default = Self::default_project_record();
2240 let hash = default.id();
2241 let body = serde_json::to_vec(&default).map_err(|e| DeployError::Serde(e.to_string()))?;
2242 self.kv
2243 .write_batch(vec![
2244 WriteOp::Put(crate::project::spec_key(&hash), body),
2245 WriteOp::Put(pointer, hash.into_bytes()),
2246 ])
2247 .await?;
2248 Ok(true)
2249 }
2250
2251 pub async fn project_exists(&self, name: &str) -> Result<bool, DeployError> {
2256 if name == crate::project::DEFAULT_PROJECT {
2257 return Ok(true);
2258 }
2259 Ok(self
2260 .kv
2261 .get(&crate::project::pointer_key(name))
2262 .await?
2263 .is_some())
2264 }
2265
2266 pub async fn get_project(
2268 &self,
2269 name: &str,
2270 ) -> Result<Option<crate::project::Project>, DeployError> {
2271 let Some(hash) = self.kv.get(&crate::project::pointer_key(name)).await? else {
2272 if name == crate::project::DEFAULT_PROJECT {
2276 return Ok(Some(Self::default_project_record()));
2277 }
2278 return Ok(None);
2279 };
2280 let hash = String::from_utf8_lossy(&hash).into_owned();
2281 match self.kv.get(&crate::project::spec_key(&hash)).await? {
2282 Some(bytes) => Ok(Some(
2283 serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
2284 )),
2285 None if name == crate::project::DEFAULT_PROJECT => {
2288 Ok(Some(Self::default_project_record()))
2289 }
2290 None => Ok(None),
2291 }
2292 }
2293
2294 pub async fn list_projects(&self) -> Result<Vec<crate::project::Project>, DeployError> {
2298 let mut out = Vec::new();
2299 for key in self.kv.list_prefix(crate::project::POINTER_PREFIX).await? {
2300 let Some(name) = key.strip_prefix(crate::project::POINTER_PREFIX) else {
2301 continue;
2302 };
2303 if !name.is_empty() {
2304 if let Some(p) = self.get_project(name).await? {
2305 out.push(p);
2306 }
2307 }
2308 }
2309 if !out
2312 .iter()
2313 .any(|p| p.name == crate::project::DEFAULT_PROJECT)
2314 {
2315 out.push(Self::default_project_record());
2316 }
2317 out.sort_by(|a, b| a.name.cmp(&b.name));
2318 Ok(out)
2319 }
2320
2321 pub async fn delete_project(&self, name: &str) -> Result<bool, DeployError> {
2327 if name == crate::project::DEFAULT_PROJECT {
2328 return Err(DeployError::Conflict(
2329 "the `default` project cannot be deleted".to_string(),
2330 ));
2331 }
2332 let existed = self
2333 .kv
2334 .get(&crate::project::pointer_key(name))
2335 .await?
2336 .is_some();
2337 if !self
2339 .kv
2340 .list_prefix(&crate::project::resource_prefix(name))
2341 .await?
2342 .is_empty()
2343 {
2344 return Err(DeployError::Conflict(format!(
2345 "project `{name}` still owns resources; delete its sites/functions/compute first"
2346 )));
2347 }
2348 self.kv
2349 .write_batch(vec![
2350 WriteOp::Delete(crate::project::pointer_key(name)),
2351 WriteOp::Delete(crate::project::history_key(name)),
2352 ])
2353 .await?;
2354 Ok(existed)
2355 }
2356
2357 pub async fn activate(
2361 &self,
2362 project: ProjectRef<'_>,
2363 site: &str,
2364 id: &str,
2365 ) -> Result<(), DeployError> {
2366 let manifest = self
2367 .get_manifest(id)
2368 .await?
2369 .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
2370 let missing = self.missing_blobs(&manifest).await?;
2371 if !missing.is_empty() {
2372 return Err(DeployError::Incomplete(missing));
2373 }
2374
2375 self.kv
2378 .put(&keys::current(project, site), id.as_bytes().to_vec())
2379 .await?;
2380
2381 let _ = self.record_history(project, site, id).await;
2385 Ok(())
2386 }
2387
2388 async fn record_history(
2391 &self,
2392 project: ProjectRef<'_>,
2393 site: &str,
2394 id: &str,
2395 ) -> Result<(), DeployError> {
2396 let mut history = self.history(project, site).await.unwrap_or_default();
2397 history.retain(|entry| entry.id != id);
2398 history.insert(
2399 0,
2400 HistoryEntry {
2401 id: id.to_string(),
2402 at: now_unix(),
2403 meta: None,
2404 },
2405 );
2406 history.truncate(MAX_HISTORY);
2407 self.kv
2408 .put(&keys::history(project, site), serde_json::to_vec(&history)?)
2409 .await?;
2410 Ok(())
2411 }
2412
2413 pub async fn history(
2415 &self,
2416 project: ProjectRef<'_>,
2417 site: &str,
2418 ) -> Result<Vec<HistoryEntry>, DeployError> {
2419 match self.kv.get(&keys::history(project, site)).await? {
2420 Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2421 None => Ok(Vec::new()),
2422 }
2423 }
2424
2425 pub async fn deployments(
2428 &self,
2429 project: ProjectRef<'_>,
2430 site: &str,
2431 ) -> Result<DeploymentList, DeployError> {
2432 let mut deployments = self.history(project, site).await?;
2433 for entry in &mut deployments {
2434 entry.meta = self.get_meta(&entry.id).await?;
2435 }
2436 Ok(DeploymentList {
2437 current: self.current_id(project, site).await?,
2438 deployments,
2439 })
2440 }
2441
2442 async fn live_deployment_ids(&self, opts: &GcOptions) -> Result<BTreeSet<String>, DeployError> {
2453 let mut ids = BTreeSet::new();
2454 let now = now_unix();
2455 for project in self.discover_projects().await? {
2456 let pref = ProjectRef::new(&project);
2457 for key in self.kv.list_prefix(&keys::history_prefix(pref)).await? {
2458 if let Some(bytes) = self.kv.get(&key).await? {
2459 if let Ok(history) = serde_json::from_slice::<Vec<HistoryEntry>>(&bytes) {
2460 for (idx, entry) in history.iter().enumerate() {
2461 let within_count = opts.keep_last.is_none_or(|n| idx < n);
2462 let within_age = opts
2463 .keep_age_secs
2464 .is_some_and(|age| now.saturating_sub(entry.at) <= age);
2465 if within_count || within_age {
2466 ids.insert(entry.id.clone());
2467 }
2468 }
2469 }
2470 }
2471 }
2472 for prefix in [keys::current_prefix(pref), keys::alias_project_prefix(pref)] {
2474 for key in self.kv.list_prefix(&prefix).await? {
2475 if let Some(bytes) = self.kv.get(&key).await? {
2476 ids.insert(String::from_utf8_lossy(&bytes).into_owned());
2477 }
2478 }
2479 }
2480 }
2481 Ok(ids)
2482 }
2483
2484 async fn within_grace(
2489 &self,
2490 id: &str,
2491 now: u64,
2492 opts: &GcOptions,
2493 ) -> Result<bool, DeployError> {
2494 if opts.grace_secs == 0 {
2495 return Ok(false);
2496 }
2497 match self.get_meta(id).await? {
2498 Some(meta) => Ok(now.saturating_sub(meta.created_at) < opts.grace_secs),
2499 None => Ok(false),
2500 }
2501 }
2502
2503 pub async fn collect_garbage(&self, prune: bool) -> Result<GcReport, DeployError> {
2510 self.collect_garbage_with(prune, GcOptions::default()).await
2511 }
2512
2513 pub async fn collect_garbage_with(
2524 &self,
2525 prune: bool,
2526 opts: GcOptions,
2527 ) -> Result<GcReport, DeployError> {
2528 let live_ids = self.live_deployment_ids(&opts).await?;
2529 let now = now_unix();
2530
2531 let manifest_keys = self.kv.list_prefix("manifests/").await?;
2532 let manifests_total = manifest_keys.len();
2533 let mut referenced: BTreeSet<String> = BTreeSet::new();
2534 let mut orphan_manifests: Vec<String> = Vec::new();
2535 for key in &manifest_keys {
2536 let id = key.strip_prefix("manifests/").unwrap_or(key);
2537 let protected = live_ids.contains(id) || self.within_grace(id, now, &opts).await?;
2538 if protected {
2539 if let Some(bytes) = self.kv.get(key).await? {
2540 if let Ok(manifest) = Manifest::from_bytes(&bytes) {
2541 referenced.extend(manifest.blob_hashes());
2542 }
2543 }
2544 } else {
2545 orphan_manifests.push(key.clone());
2546 }
2547 }
2548
2549 let blobs = self.storage.list("").await?;
2550 let blobs_total = blobs.len();
2551 let mut blobs_removed = 0;
2552 let mut bytes_reclaimed = 0;
2553 for meta in &blobs {
2554 if !is_blob_key(&meta.key) {
2555 continue;
2556 }
2557 let hash = meta.key.rsplit('/').next().unwrap_or(&meta.key);
2558 if !referenced.contains(hash) {
2559 blobs_removed += 1;
2560 bytes_reclaimed += meta.size.unwrap_or(0);
2561 if prune {
2562 self.storage.delete(&meta.key).await?;
2563 }
2564 }
2565 }
2566
2567 if prune {
2573 let mut referenced_configs: BTreeSet<String> = BTreeSet::new();
2577 for project in self.discover_projects().await? {
2578 let site_prefix = keys::site_prefix(ProjectRef::new(&project));
2579 for pointer in self.kv.list_prefix(&site_prefix).await? {
2580 if let Some(bytes) = self.kv.get(&pointer).await? {
2581 referenced_configs.insert(String::from_utf8_lossy(&bytes).into_owned());
2582 }
2583 }
2584 }
2585 for key in self.kv.list_prefix("siteconfig/").await? {
2586 let hash = key.strip_prefix("siteconfig/").unwrap_or(&key);
2587 if !referenced_configs.contains(hash) {
2588 self.kv.delete(&key).await?;
2589 }
2590 }
2591 }
2592
2593 let manifests_removed = orphan_manifests.len();
2594 if prune {
2595 for key in &orphan_manifests {
2596 self.kv.delete(key).await?;
2597 if let Some(id) = key.strip_prefix("manifests/") {
2599 let _ = self.kv.delete(&keys::meta(id)).await;
2600 }
2601 }
2602 }
2603
2604 Ok(GcReport {
2605 manifests_total,
2606 manifests_removed,
2607 blobs_total,
2608 blobs_removed,
2609 bytes_reclaimed,
2610 })
2611 }
2612
2613 pub async fn scrub_blobs(&self) -> Result<ScrubReport, DeployError> {
2619 let blobs = self.storage.list("").await?;
2620 let mut report = ScrubReport::default();
2621 for meta in &blobs {
2622 if !is_blob_key(&meta.key) {
2623 continue;
2624 }
2625 report.checked += 1;
2626 let expected = meta
2627 .key
2628 .rsplit('/')
2629 .next()
2630 .unwrap_or(meta.key.as_str())
2631 .to_string();
2632 match self.hash_stored_object(&meta.key).await {
2633 Ok(actual) if actual == expected => {}
2634 Ok(actual) => report.mismatched.push(BlobMismatch {
2635 key: meta.key.clone(),
2636 expected,
2637 actual,
2638 }),
2639 Err(err) => report.errors.push(BlobReadError {
2640 key: meta.key.clone(),
2641 error: err.to_string(),
2642 }),
2643 }
2644 }
2645 Ok(report)
2646 }
2647
2648 pub fn invalidate_cache_keys(&self, keys: &[String]) {
2654 self.kv.invalidate_keys(keys);
2655 }
2656
2657 pub fn invalidate_cache(&self) {
2660 self.kv.invalidate_cache();
2661 }
2662
2663 pub async fn cert_status(&self) -> Result<Vec<crate::cert::CertStatus>, DeployError> {
2668 let mut out = Vec::new();
2669 for key in self.kv.list_prefix("cert/").await? {
2670 let domain = key.strip_prefix("cert/").unwrap_or(&key).to_string();
2671 if let Some(bytes) = self.kv.get(&key).await? {
2672 if let Ok(cert) = serde_json::from_slice::<crate::cert::StoredCert>(&bytes) {
2673 out.push(crate::cert::CertStatus {
2674 domain,
2675 not_after_unix: cert.not_after_unix,
2676 });
2677 }
2678 }
2679 }
2680 out.sort_by(|a, b| a.domain.cmp(&b.domain));
2681 Ok(out)
2682 }
2683
2684 async fn hash_stored_object(&self, key: &str) -> Result<String, DeployError> {
2686 let mut body = self.storage.get(key).await?.body;
2687 let mut hasher = Sha256::new();
2688 while let Some(chunk) = body.next().await {
2689 hasher.update(&chunk?);
2690 }
2691 Ok(hex::encode(hasher.finalize()))
2692 }
2693
2694 pub async fn list_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
2698 let prefix = keys::current_prefix(project);
2699 let keys = self.kv.list_prefix(&prefix).await?;
2700 Ok(keys
2701 .into_iter()
2702 .filter_map(|k| k.strip_prefix(&prefix).map(str::to_string))
2703 .collect())
2704 }
2705
2706 pub async fn list_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
2709 let mut out = Vec::new();
2710 for project in self.discover_projects().await? {
2711 for site in self.list_sites(ProjectRef::new(&project)).await? {
2712 out.push((project.clone(), site));
2713 }
2714 }
2715 Ok(out)
2716 }
2717
2718 pub async fn delete_site(
2725 &self,
2726 project: ProjectRef<'_>,
2727 site: &str,
2728 ) -> Result<(), DeployError> {
2729 use crate::kv::WriteOp;
2730 let _claim = self.domain_claim_lock.lock().await;
2733 let mut batch = vec![
2734 WriteOp::Delete(keys::site_pointer(project, site)),
2735 WriteOp::Delete(keys::current(project, site)),
2736 WriteOp::Delete(keys::history(project, site)),
2737 ];
2738 if let Some(config) = self.get_site_config(project, site).await? {
2739 for host in config.domains.exact_hosts() {
2740 batch.push(WriteOp::Delete(keys::domain(host)));
2741 }
2742 for wildcard in &config.domains.wildcards {
2743 if let Some(suffix) = wildcard.strip_prefix("*.") {
2744 batch.push(WriteOp::Delete(keys::wildcard(suffix)));
2745 }
2746 }
2747 }
2748 for key in self
2749 .kv
2750 .list_prefix(&keys::alias_prefix(project, site))
2751 .await?
2752 {
2753 batch.push(WriteOp::Delete(key));
2754 }
2755 for key in self
2756 .kv
2757 .list_prefix(&keys::domain_verification_prefix(project, site))
2758 .await?
2759 {
2760 batch.push(WriteOp::Delete(key));
2761 }
2762 self.kv.write_batch(batch).await?;
2763 Ok(())
2764 }
2765
2766 pub async fn current_id(
2768 &self,
2769 project: ProjectRef<'_>,
2770 site: &str,
2771 ) -> Result<Option<String>, DeployError> {
2772 match self.kv.get(&keys::current(project, site)).await? {
2773 Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
2774 None => Ok(None),
2775 }
2776 }
2777
2778 pub async fn current_manifest(
2780 &self,
2781 project: ProjectRef<'_>,
2782 site: &str,
2783 ) -> Result<Option<Manifest>, DeployError> {
2784 match self.current_id(project, site).await? {
2785 Some(id) => self.get_manifest(&id).await,
2786 None => Ok(None),
2787 }
2788 }
2789
2790 pub async fn resolve(
2795 &self,
2796 project: ProjectRef<'_>,
2797 site: &str,
2798 path: &str,
2799 ) -> Result<Option<FileEntry>, DeployError> {
2800 let Some(manifest) = self.current_manifest(project, site).await? else {
2801 return Ok(None);
2802 };
2803 Ok(lookup(&manifest, path))
2804 }
2805}
2806
2807fn lookup(manifest: &Manifest, path: &str) -> Option<FileEntry> {
2809 let trimmed = path.trim_start_matches('/');
2810 if let Some(entry) = manifest.files.get(trimmed) {
2811 return Some(entry.clone());
2812 }
2813 let index = if trimmed.is_empty() {
2814 "index.html".to_string()
2815 } else {
2816 format!("{}/index.html", trimmed.trim_end_matches('/'))
2817 };
2818 manifest.files.get(&index).cloned()
2819}
2820
2821#[cfg(test)]
2822mod tests {
2823 use super::*;
2824 use crate::config::DeployConfig;
2825 use crate::ObjectMeta;
2826
2827 fn entry(hash: &str) -> FileEntry {
2828 FileEntry {
2829 hash: hash.to_string(),
2830 size: 0,
2831 content_type: None,
2832 variants: BTreeMap::new(),
2833 }
2834 }
2835
2836 #[test]
2837 fn manifest_id_is_deterministic() {
2838 let mut a = Manifest::default();
2839 a.files.insert("index.html".into(), entry("aa"));
2840 a.files.insert("style.css".into(), entry("bb"));
2841
2842 let mut b = Manifest::default();
2843 b.files.insert("style.css".into(), entry("bb"));
2845 b.files.insert("index.html".into(), entry("aa"));
2846
2847 assert_eq!(a.id().unwrap(), b.id().unwrap());
2848 }
2849
2850 #[test]
2851 fn manifest_carries_schema_version_and_reads_legacy() {
2852 let manifest = Manifest::default();
2854 assert_eq!(manifest.version, crate::SCHEMA_VERSION);
2855 assert!(manifest.to_bytes().unwrap().starts_with(b"{\"version\":1"));
2856
2857 let legacy = br#"{"files":{},"config":{}}"#;
2859 assert_eq!(Manifest::from_bytes(legacy).unwrap().version, 1);
2860 }
2861
2862 #[test]
2863 fn directory_index_fallback() {
2864 let mut m = Manifest::default();
2865 m.files.insert("index.html".into(), entry("root"));
2866 m.files.insert("blog/index.html".into(), entry("blog"));
2867
2868 assert_eq!(lookup(&m, "").unwrap().hash, "root");
2869 assert_eq!(lookup(&m, "/").unwrap().hash, "root");
2870 assert_eq!(lookup(&m, "blog").unwrap().hash, "blog");
2871 assert_eq!(lookup(&m, "blog/").unwrap().hash, "blog");
2872 assert!(lookup(&m, "missing.html").is_none());
2873 }
2874
2875 struct NullStorage;
2878
2879 #[async_trait::async_trait]
2880 impl Storage for NullStorage {
2881 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
2882 Err(StorageError::NotFound(String::new()))
2883 }
2884 async fn get_range(
2885 &self,
2886 _: &str,
2887 _: u64,
2888 _: Option<u64>,
2889 ) -> Result<GetObject, StorageError> {
2890 Err(StorageError::NotFound(String::new()))
2891 }
2892 async fn put(
2893 &self,
2894 _: &str,
2895 _: ByteStream,
2896 _: PutMeta,
2897 ) -> Result<ObjectMeta, StorageError> {
2898 Err(StorageError::unsupported("null"))
2899 }
2900 async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
2901 Err(StorageError::NotFound(String::new()))
2902 }
2903 async fn delete(&self, _: &str) -> Result<(), StorageError> {
2904 Ok(())
2905 }
2906 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2907 Ok(Vec::new())
2908 }
2909 }
2910
2911 #[derive(Default)]
2913 struct MemStorage {
2914 objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
2915 }
2916
2917 #[async_trait::async_trait]
2918 impl Storage for MemStorage {
2919 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2920 let bytes = self
2921 .objects
2922 .lock()
2923 .unwrap()
2924 .get(key)
2925 .cloned()
2926 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2927 let size = bytes.len() as u64;
2928 let body: ByteStream =
2929 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2930 Ok(GetObject {
2931 meta: ObjectMeta {
2932 key: key.to_string(),
2933 size: Some(size),
2934 ..Default::default()
2935 },
2936 body,
2937 })
2938 }
2939 async fn get_range(
2940 &self,
2941 key: &str,
2942 _: u64,
2943 _: Option<u64>,
2944 ) -> Result<GetObject, StorageError> {
2945 self.get(key).await
2946 }
2947 async fn put(
2948 &self,
2949 key: &str,
2950 mut body: ByteStream,
2951 _: PutMeta,
2952 ) -> Result<ObjectMeta, StorageError> {
2953 let mut buf = Vec::new();
2954 while let Some(chunk) = body.next().await {
2955 buf.extend_from_slice(&chunk?);
2956 }
2957 let size = buf.len() as u64;
2958 self.objects.lock().unwrap().insert(key.to_string(), buf);
2959 Ok(ObjectMeta {
2960 key: key.to_string(),
2961 size: Some(size),
2962 ..Default::default()
2963 })
2964 }
2965 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2966 let map = self.objects.lock().unwrap();
2967 let bytes = map
2968 .get(key)
2969 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2970 Ok(ObjectMeta {
2971 key: key.to_string(),
2972 size: Some(bytes.len() as u64),
2973 ..Default::default()
2974 })
2975 }
2976 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2977 self.objects.lock().unwrap().remove(key);
2978 Ok(())
2979 }
2980 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2981 Ok(self
2982 .objects
2983 .lock()
2984 .unwrap()
2985 .keys()
2986 .filter(|k| k.starts_with(prefix))
2987 .map(|k| ObjectMeta {
2988 key: k.clone(),
2989 ..Default::default()
2990 })
2991 .collect())
2992 }
2993 }
2994
2995 fn once_bytes(b: &'static [u8]) -> ByteStream {
2997 futures::stream::once(async move { Ok(bytes::Bytes::from_static(b)) }).boxed()
2998 }
2999
3000 fn manifest_with(files: &[(&str, &str)]) -> Manifest {
3002 let mut m = Manifest::default();
3003 for (path, hash) in files {
3004 m.files.insert(
3005 (*path).to_string(),
3006 FileEntry {
3007 hash: (*hash).to_string(),
3008 size: 1,
3009 content_type: None,
3010 variants: Default::default(),
3011 },
3012 );
3013 }
3014 m
3015 }
3016
3017 #[tokio::test]
3018 async fn function_storage_versioning_alias_rollback() {
3019 use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
3020 use crate::kv::MemoryKv;
3021
3022 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3023 assert!(store
3024 .list_stored_functions(ProjectRef::DEFAULT)
3025 .await
3026 .unwrap()
3027 .is_empty());
3028
3029 let mut f = Function::new(
3030 "resize",
3031 Owner::Project("acme".into()),
3032 "hashA",
3033 FunctionConfig::default(),
3034 Lifecycle::Independent,
3035 1,
3036 );
3037 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3038 assert_eq!(
3039 store
3040 .get_function(ProjectRef::DEFAULT, "resize")
3041 .await
3042 .unwrap()
3043 .unwrap()
3044 .active,
3045 "hashA"
3046 );
3047 assert_eq!(
3048 store
3049 .list_stored_functions(ProjectRef::DEFAULT)
3050 .await
3051 .unwrap()
3052 .len(),
3053 1
3054 );
3055
3056 f.upsert_version("hashB", Lifecycle::Independent, 2);
3058 f.set_alias("prod", "hashA").unwrap();
3059 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3060 let got = store
3061 .get_function(ProjectRef::DEFAULT, "resize")
3062 .await
3063 .unwrap()
3064 .unwrap();
3065 assert_eq!(got.active, "hashB");
3066 assert_eq!(got.aliases.get("prod").map(String::as_str), Some("hashA"));
3067
3068 assert!(store
3070 .delete_function(ProjectRef::DEFAULT, "resize")
3071 .await
3072 .unwrap());
3073 assert!(store
3074 .get_function(ProjectRef::DEFAULT, "resize")
3075 .await
3076 .unwrap()
3077 .is_none());
3078 assert!(!store
3079 .delete_function(ProjectRef::DEFAULT, "resize")
3080 .await
3081 .unwrap());
3082 }
3083
3084 #[tokio::test]
3085 async fn function_invocation_and_idempotency_storage() {
3086 use crate::function::{
3087 Function, FunctionConfig, Invocation, InvocationResult, InvocationStatus, InvokeMode,
3088 Lifecycle, Owner,
3089 };
3090 use crate::kv::MemoryKv;
3091
3092 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3093 let f = Function::new(
3095 "greeter",
3096 Owner::Project("acme".into()),
3097 "hashA",
3098 FunctionConfig::default(),
3099 Lifecycle::Independent,
3100 1,
3101 );
3102 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3103
3104 let mut inv = Invocation {
3105 id: "inv-1".into(),
3106 function: "greeter".into(),
3107 version: "hashA".into(),
3108 mode: InvokeMode::Async,
3109 status: InvocationStatus::Queued,
3110 idempotency_key: Some("key-1".into()),
3111 attempts: 0,
3112 lease_expires: None,
3113 request_b64: None,
3114 request_content_type: None,
3115 result: None,
3116 created: 10,
3117 updated: 10,
3118 };
3119 store
3120 .put_invocation(ProjectRef::DEFAULT, &inv)
3121 .await
3122 .unwrap();
3123 store
3124 .put_idempotency(ProjectRef::DEFAULT, "greeter", "key-1", "inv-1")
3125 .await
3126 .unwrap();
3127
3128 assert_eq!(
3130 store
3131 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3132 .await
3133 .unwrap()
3134 .unwrap()
3135 .status,
3136 InvocationStatus::Queued
3137 );
3138 assert_eq!(
3139 store
3140 .get_idempotency(ProjectRef::DEFAULT, "greeter", "key-1")
3141 .await
3142 .unwrap(),
3143 Some("inv-1".to_string())
3144 );
3145 assert_eq!(
3146 store
3147 .list_invocations(ProjectRef::DEFAULT, "greeter")
3148 .await
3149 .unwrap()
3150 .len(),
3151 1
3152 );
3153 assert_eq!(
3155 store
3156 .list_stored_functions(ProjectRef::DEFAULT)
3157 .await
3158 .unwrap()
3159 .len(),
3160 1
3161 );
3162
3163 inv.status = InvocationStatus::Succeeded;
3165 inv.attempts = 1;
3166 inv.result = Some(InvocationResult {
3167 status: 200,
3168 content_type: Some("text/plain".into()),
3169 body_b64: "aGVsbG8=".into(),
3170 });
3171 inv.updated = 20;
3172 store
3173 .put_invocation(ProjectRef::DEFAULT, &inv)
3174 .await
3175 .unwrap();
3176 let got = store
3177 .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3178 .await
3179 .unwrap()
3180 .unwrap();
3181 assert!(got.is_terminal());
3182 assert_eq!(got.result.unwrap().status, 200);
3183
3184 assert!(store
3186 .get_idempotency(ProjectRef::DEFAULT, "greeter", "absent")
3187 .await
3188 .unwrap()
3189 .is_none());
3190 }
3191
3192 #[tokio::test]
3193 async fn notification_ledger_provisions_and_retracts_through_the_store() {
3194 use crate::blob_notify::{ManagedResource, ProvisionTier};
3195 use crate::blob_provision::{ensure_watch, retract_watch, ProvisionError, WatchProvider};
3196 use crate::kv::MemoryKv;
3197
3198 struct StoreMock;
3200 #[async_trait::async_trait]
3201 impl WatchProvider for StoreMock {
3202 fn name(&self) -> &str {
3203 "mock"
3204 }
3205 fn recipe(&self, _prefix: &str) -> String {
3206 String::new()
3207 }
3208 async fn provision(
3209 &self,
3210 _prefix: &str,
3211 ) -> Result<Vec<ManagedResource>, ProvisionError> {
3212 Ok(vec![ManagedResource::new("queue", "q-1")])
3213 }
3214 async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
3215 Ok(true)
3216 }
3217 async fn retract(&self, _res: &[ManagedResource]) -> Result<(), ProvisionError> {
3218 Ok(())
3219 }
3220 }
3221
3222 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3223 assert!(store
3224 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3225 .await
3226 .unwrap()
3227 .is_none());
3228
3229 let provider = StoreMock;
3231 let out = ensure_watch(
3232 &provider,
3233 ProvisionTier::Provision,
3234 "ingest",
3235 "uploads/",
3236 &store,
3237 7,
3238 )
3239 .await
3240 .unwrap();
3241 assert!(matches!(
3242 out,
3243 crate::blob_provision::ProvisionOutcome::Ready
3244 ));
3245 let record = store
3246 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3247 .await
3248 .unwrap()
3249 .expect("the pipeline is recorded in the store ledger");
3250 assert_eq!(record.provider, "mock");
3251 assert_eq!(
3252 store
3253 .list_managed_notifications(ProjectRef::DEFAULT, "ingest")
3254 .await
3255 .unwrap()
3256 .len(),
3257 1
3258 );
3259
3260 retract_watch(&provider, &record, &store).await.unwrap();
3262 assert!(store
3263 .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3264 .await
3265 .unwrap()
3266 .is_none());
3267 }
3268
3269 #[tokio::test]
3270 async fn workflow_definition_and_run_storage() {
3271 use crate::kv::MemoryKv;
3272 use crate::workflow::{Step, Workflow, WorkflowRun};
3273
3274 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3275 assert!(store
3276 .get_workflow(ProjectRef::DEFAULT, "etl")
3277 .await
3278 .unwrap()
3279 .is_none());
3280 assert!(store
3281 .list_workflows(ProjectRef::DEFAULT)
3282 .await
3283 .unwrap()
3284 .is_empty());
3285
3286 let wf = Workflow {
3287 name: "etl".into(),
3288 steps: vec![
3289 Step {
3290 id: "a".into(),
3291 function: "extract".into(),
3292 depends_on: vec![],
3293 retry: Default::default(),
3294 compensate: None,
3295 },
3296 Step {
3297 id: "b".into(),
3298 function: "load".into(),
3299 depends_on: vec!["a".into()],
3300 retry: Default::default(),
3301 compensate: None,
3302 },
3303 ],
3304 };
3305 store.put_workflow(ProjectRef::DEFAULT, &wf).await.unwrap();
3306 assert_eq!(
3307 store
3308 .get_workflow(ProjectRef::DEFAULT, "etl")
3309 .await
3310 .unwrap()
3311 .unwrap(),
3312 wf
3313 );
3314 assert_eq!(
3315 store
3316 .list_workflows(ProjectRef::DEFAULT)
3317 .await
3318 .unwrap()
3319 .len(),
3320 1
3321 );
3322
3323 let run = WorkflowRun::start(&wf, "r1", None, 5);
3325 store
3326 .put_workflow_run(ProjectRef::DEFAULT, &run)
3327 .await
3328 .unwrap();
3329 assert_eq!(
3330 store
3331 .get_workflow_run(ProjectRef::DEFAULT, "etl", "r1")
3332 .await
3333 .unwrap()
3334 .unwrap(),
3335 run
3336 );
3337 assert_eq!(
3338 store
3339 .list_workflow_runs(ProjectRef::DEFAULT, "etl")
3340 .await
3341 .unwrap()
3342 .len(),
3343 1
3344 );
3345 assert_eq!(
3347 store
3348 .list_workflows(ProjectRef::DEFAULT)
3349 .await
3350 .unwrap()
3351 .len(),
3352 1
3353 );
3354
3355 assert!(store
3357 .delete_workflow(ProjectRef::DEFAULT, "etl")
3358 .await
3359 .unwrap());
3360 assert!(store
3361 .get_workflow(ProjectRef::DEFAULT, "etl")
3362 .await
3363 .unwrap()
3364 .is_none());
3365 assert!(!store
3366 .delete_workflow(ProjectRef::DEFAULT, "etl")
3367 .await
3368 .unwrap());
3369 }
3370
3371 #[tokio::test]
3372 async fn function_trigger_storage_round_trips() {
3373 use crate::function::{
3374 Function, FunctionConfig, FunctionTrigger, Lifecycle, Owner, TriggerKind,
3375 };
3376 use crate::kv::MemoryKv;
3377
3378 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3379 let f = Function::new(
3380 "worker",
3381 Owner::Project("acme".into()),
3382 "hashA",
3383 FunctionConfig::default(),
3384 Lifecycle::Independent,
3385 1,
3386 );
3387 store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3388 assert!(store
3389 .list_triggers(ProjectRef::DEFAULT, "worker")
3390 .await
3391 .unwrap()
3392 .is_empty());
3393
3394 let cron = FunctionTrigger {
3395 id: "tick".into(),
3396 kind: TriggerKind::Cron {
3397 schedule: "* * * * *".into(),
3398 overlap: Default::default(),
3399 },
3400 last_fired_minute: None,
3401 };
3402 store
3403 .put_trigger(ProjectRef::DEFAULT, "worker", &cron)
3404 .await
3405 .unwrap();
3406 let queue = FunctionTrigger {
3407 id: "jobs".into(),
3408 kind: TriggerKind::Queue {
3409 topic: "jobs".into(),
3410 group: String::new(),
3411 start: Default::default(),
3412 },
3413 last_fired_minute: None,
3414 };
3415 store
3416 .put_trigger(ProjectRef::DEFAULT, "worker", &queue)
3417 .await
3418 .unwrap();
3419
3420 assert_eq!(
3421 store
3422 .list_triggers(ProjectRef::DEFAULT, "worker")
3423 .await
3424 .unwrap()
3425 .len(),
3426 2
3427 );
3428 assert_eq!(
3429 store
3430 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3431 .await
3432 .unwrap()
3433 .unwrap(),
3434 cron
3435 );
3436 assert_eq!(
3438 store
3439 .list_stored_functions(ProjectRef::DEFAULT)
3440 .await
3441 .unwrap()
3442 .len(),
3443 1
3444 );
3445
3446 let mut fired = cron.clone();
3448 fired.last_fired_minute = Some(42);
3449 store
3450 .put_trigger(ProjectRef::DEFAULT, "worker", &fired)
3451 .await
3452 .unwrap();
3453 assert_eq!(
3454 store
3455 .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
3456 .await
3457 .unwrap()
3458 .unwrap()
3459 .last_fired_minute,
3460 Some(42)
3461 );
3462
3463 assert!(store
3465 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3466 .await
3467 .unwrap());
3468 assert!(!store
3469 .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
3470 .await
3471 .unwrap());
3472 assert_eq!(
3473 store
3474 .list_triggers(ProjectRef::DEFAULT, "worker")
3475 .await
3476 .unwrap()
3477 .len(),
3478 1
3479 );
3480 }
3481
3482 #[tokio::test]
3483 async fn function_metering_storage_is_tenant_isolated() {
3484 use crate::function::{Metering, MeteringSample};
3485 use crate::kv::MemoryKv;
3486
3487 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3488 assert!(store
3489 .get_metering(ProjectRef::DEFAULT, "a")
3490 .await
3491 .unwrap()
3492 .is_none());
3493
3494 let mut ma = Metering::new("a");
3495 ma.record(
3496 &MeteringSample {
3497 success: true,
3498 duration_ms: 4,
3499 bytes_in: 1,
3500 bytes_out: 2,
3501 },
3502 10,
3503 );
3504 store.put_metering(ProjectRef::DEFAULT, &ma).await.unwrap();
3505
3506 let mut mb = Metering::new("b");
3507 mb.record(
3508 &MeteringSample {
3509 success: false,
3510 duration_ms: 9,
3511 bytes_in: 0,
3512 bytes_out: 0,
3513 },
3514 11,
3515 );
3516 store.put_metering(ProjectRef::DEFAULT, &mb).await.unwrap();
3517
3518 assert_eq!(
3520 store
3521 .get_metering(ProjectRef::DEFAULT, "a")
3522 .await
3523 .unwrap()
3524 .unwrap()
3525 .successes,
3526 1
3527 );
3528 assert_eq!(
3529 store
3530 .get_metering(ProjectRef::DEFAULT, "b")
3531 .await
3532 .unwrap()
3533 .unwrap()
3534 .failures,
3535 1
3536 );
3537 let all = store.list_metering(ProjectRef::DEFAULT).await.unwrap();
3538 assert_eq!(all.len(), 2);
3539 }
3540
3541 #[tokio::test]
3542 async fn managed_dns_ledger_round_trip_and_retract() {
3543 use crate::dns_managed::{ManagedDns, ManagedRecord};
3544 use crate::kv::MemoryKv;
3545
3546 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3547 assert!(store
3548 .get_managed_dns(
3549 ProjectRef::DEFAULT,
3550 &SiteName::new("blog"),
3551 "www.example.com"
3552 )
3553 .await
3554 .unwrap()
3555 .is_none());
3556
3557 let ledger = ManagedDns::new(
3558 "www.example.com",
3559 "cloudflare",
3560 vec![ManagedRecord {
3561 kind: "A".into(),
3562 name: "www.example.com".into(),
3563 value: "203.0.113.7".into(),
3564 ttl: 300,
3565 }],
3566 10,
3567 );
3568 store
3569 .set_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"), &ledger)
3570 .await
3571 .unwrap();
3572 assert_eq!(
3574 store
3575 .get_managed_dns(
3576 ProjectRef::DEFAULT,
3577 &SiteName::new("blog"),
3578 "WWW.example.com."
3579 )
3580 .await
3581 .unwrap(),
3582 Some(ledger.clone())
3583 );
3584 assert_eq!(
3585 store
3586 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3587 .await
3588 .unwrap(),
3589 vec![ledger]
3590 );
3591
3592 store
3593 .remove_managed_dns(
3594 ProjectRef::DEFAULT,
3595 &SiteName::new("blog"),
3596 "www.example.com",
3597 )
3598 .await
3599 .unwrap();
3600 assert!(store
3601 .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
3602 .await
3603 .unwrap()
3604 .is_empty());
3605 }
3606
3607 #[tokio::test]
3608 async fn site_config_round_trip_and_host_routing() {
3609 use crate::config::{DomainConfig, SiteConfig};
3610 use crate::kv::MemoryKv;
3611
3612 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3613 let config = SiteConfig {
3614 domains: DomainConfig {
3615 primary: Some("example.com".into()),
3616 aliases: vec!["www.example.com".into()],
3617 wildcards: vec!["*.example.com".into()],
3618 ..Default::default()
3619 },
3620 ..Default::default()
3621 };
3622 store
3623 .set_site_config(ProjectRef::DEFAULT, "blog", &config)
3624 .await
3625 .unwrap();
3626
3627 let resolved = |host: &'static str| {
3628 let store = store.clone();
3629 async move {
3630 store
3631 .resolve_site_by_host(host)
3632 .await
3633 .unwrap()
3634 .map(|o| o.site)
3635 }
3636 };
3637 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);
3642
3643 store
3645 .set_site_config(ProjectRef::DEFAULT, "blog", &SiteConfig::default())
3646 .await
3647 .unwrap();
3648 assert_eq!(resolved("example.com").await, None);
3649 }
3650
3651 #[tokio::test]
3657 async fn wildcard_vhost_precedence_exact_beats_wildcard() {
3658 use crate::config::{DomainConfig, SiteConfig};
3659 use crate::kv::MemoryKv;
3660
3661 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3662 let attach = |site: &'static str, domains: DomainConfig| {
3663 let store = store.clone();
3664 async move {
3665 store
3666 .set_site_config(
3667 ProjectRef::DEFAULT,
3668 site,
3669 &SiteConfig {
3670 domains,
3671 ..Default::default()
3672 },
3673 )
3674 .await
3675 .unwrap();
3676 }
3677 };
3678 attach(
3680 "portal",
3681 DomainConfig {
3682 wildcards: vec!["*.construens.com".into()],
3683 ..Default::default()
3684 },
3685 )
3686 .await;
3687 attach(
3689 "console",
3690 DomainConfig {
3691 primary: Some("console.construens.com".into()),
3692 ..Default::default()
3693 },
3694 )
3695 .await;
3696 attach(
3698 "vip",
3699 DomainConfig {
3700 primary: Some("vip.construens.com".into()),
3701 ..Default::default()
3702 },
3703 )
3704 .await;
3705
3706 let resolved = |host: &'static str| {
3707 let store = store.clone();
3708 async move {
3709 store
3710 .resolve_site_by_host(host)
3711 .await
3712 .unwrap()
3713 .map(|o| o.site)
3714 }
3715 };
3716 assert_eq!(
3718 resolved("console.construens.com").await.as_deref(),
3719 Some("console")
3720 );
3721 assert_eq!(resolved("vip.construens.com").await.as_deref(), Some("vip"));
3722 assert_eq!(
3724 resolved("tenant7.construens.com").await.as_deref(),
3725 Some("portal")
3726 );
3727 assert_eq!(
3728 resolved("anything-else.construens.com").await.as_deref(),
3729 Some("portal")
3730 );
3731 assert_eq!(
3732 resolved("deep.team.construens.com").await.as_deref(),
3733 Some("portal")
3734 );
3735 assert_eq!(resolved("construens.com").await, None);
3737 assert_eq!(resolved("console.example.com").await, None);
3738 }
3739
3740 #[tokio::test]
3745 async fn wildcard_attaches_and_routes_without_real_dns_admin_override() {
3746 use crate::domain_verify::VerificationMethod;
3747 use crate::kv::MemoryKv;
3748
3749 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3750 let site = SiteName::new("portal");
3751 store
3754 .start_domain_verification(
3755 ProjectRef::DEFAULT,
3756 &site,
3757 "*.construens.com",
3758 VerificationMethod::Dns,
3759 0,
3760 )
3761 .await
3762 .unwrap();
3763 store
3764 .mark_domain_verified(ProjectRef::DEFAULT, &site, "*.construens.com")
3765 .await
3766 .unwrap();
3767 store
3768 .attach_verified_domain(ProjectRef::DEFAULT, &site, "*.construens.com")
3769 .await
3770 .unwrap();
3771
3772 assert_eq!(
3774 store
3775 .resolve_site_by_host("tenant7.construens.com")
3776 .await
3777 .unwrap()
3778 .map(|o| o.site)
3779 .as_deref(),
3780 Some("portal")
3781 );
3782 assert_eq!(
3784 store
3785 .resolve_site_by_host("construens.com")
3786 .await
3787 .unwrap()
3788 .map(|o| o.site),
3789 None
3790 );
3791 }
3792
3793 #[tokio::test]
3794 async fn site_config_is_content_addressed_and_dedups() {
3795 use crate::config::SiteConfig;
3796 use crate::kv::MemoryKv;
3797
3798 let kv = Arc::new(MemoryKv::new());
3799 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3800
3801 let mut cfg = SiteConfig::default();
3805 cfg.security.https_redirect = true;
3806 store
3808 .set_site_config(ProjectRef::DEFAULT, "s1", &cfg)
3809 .await
3810 .unwrap();
3811 let mut cfg2 = cfg.clone();
3812 cfg2.security.https_redirect = false;
3813 store
3814 .set_site_config(ProjectRef::DEFAULT, "s2", &cfg2)
3815 .await
3816 .unwrap();
3817 store
3818 .set_site_config(ProjectRef::DEFAULT, "s3", &cfg)
3819 .await
3820 .unwrap(); let bodies = kv.list_prefix("siteconfig/").await.unwrap();
3824 assert_eq!(bodies.len(), 2, "s1/s3 share a body; s2 distinct");
3825 let pointers = kv.list_prefix("project/default/site/").await.unwrap();
3826 assert_eq!(pointers.len(), 3);
3827
3828 assert!(
3830 store
3831 .get_site_config(ProjectRef::DEFAULT, "s1")
3832 .await
3833 .unwrap()
3834 .unwrap()
3835 .security
3836 .https_redirect
3837 );
3838 assert_eq!(
3839 store
3840 .get_site_config(ProjectRef::DEFAULT, "missing")
3841 .await
3842 .unwrap(),
3843 None
3844 );
3845
3846 let mut edited = cfg.clone();
3849 edited.security.frame_options = Some("DENY".into());
3850 store
3851 .set_site_config(ProjectRef::DEFAULT, "s1", &edited)
3852 .await
3853 .unwrap();
3854 store.collect_garbage(true).await.unwrap();
3855 assert_eq!(kv.list_prefix("siteconfig/").await.unwrap().len(), 3);
3857 store
3859 .set_site_config(ProjectRef::DEFAULT, "s3", &edited)
3860 .await
3861 .unwrap();
3862 store.collect_garbage(true).await.unwrap();
3863 let remaining = kv.list_prefix("siteconfig/").await.unwrap();
3864 assert_eq!(remaining.len(), 2, "orphaned shared body reclaimed");
3865 assert!(
3867 store
3868 .get_site_config(ProjectRef::DEFAULT, "s1")
3869 .await
3870 .unwrap()
3871 .unwrap()
3872 .security
3873 .https_redirect
3874 );
3875 assert!(
3876 !store
3877 .get_site_config(ProjectRef::DEFAULT, "s2")
3878 .await
3879 .unwrap()
3880 .unwrap()
3881 .security
3882 .https_redirect
3883 );
3884 }
3885
3886 #[tokio::test]
3887 async fn cert_status_lists_domains_and_expiry_without_keys() {
3888 use crate::cert::StoredCert;
3889 use crate::kv::MemoryKv;
3890
3891 let kv = Arc::new(MemoryKv::new());
3892 let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3893 for (domain, not_after) in [("b.example.com", 2000u64), ("a.example.com", 1000u64)] {
3895 let cert = StoredCert::new("CHAINPEM", "KEYPEM", not_after);
3896 kv.put(
3897 &crate::cert::cert_key(domain),
3898 serde_json::to_vec(&cert).unwrap(),
3899 )
3900 .await
3901 .unwrap();
3902 }
3903 kv.put("site/x/config", b"{}".to_vec()).await.unwrap();
3904
3905 let status = store.cert_status().await.unwrap();
3906 assert_eq!(status.len(), 2);
3907 assert_eq!(status[0].domain, "a.example.com");
3909 assert_eq!(status[0].not_after_unix, 1000);
3910 assert_eq!(status[1].domain, "b.example.com");
3911 }
3912
3913 #[tokio::test]
3914 async fn domain_verification_gates_attachment() {
3915 use crate::domain_verify::VerificationMethod;
3916
3917 let store = store();
3918
3919 let v1 = store
3921 .start_domain_verification(
3922 ProjectRef::DEFAULT,
3923 &SiteName::new("blog"),
3924 "example.com",
3925 VerificationMethod::Dns,
3926 100,
3927 )
3928 .await
3929 .unwrap();
3930 let v2 = store
3931 .start_domain_verification(
3932 ProjectRef::DEFAULT,
3933 &SiteName::new("blog"),
3934 "example.com",
3935 VerificationMethod::Dns,
3936 200,
3937 )
3938 .await
3939 .unwrap();
3940 assert_eq!(v1.token, v2.token, "same method → same pending token");
3941 assert!(!store
3942 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3943 .await
3944 .unwrap());
3945
3946 assert!(store
3948 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3949 .await
3950 .is_err());
3951
3952 store
3954 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3955 .await
3956 .unwrap();
3957 assert!(store
3958 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3959 .await
3960 .unwrap());
3961 store
3962 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
3963 .await
3964 .unwrap();
3965 assert_eq!(
3966 store
3967 .resolve_site_by_host("example.com")
3968 .await
3969 .unwrap()
3970 .map(|o| o.site)
3971 .as_deref(),
3972 Some("blog")
3973 );
3974 store
3976 .start_domain_verification(
3977 ProjectRef::DEFAULT,
3978 &SiteName::new("blog"),
3979 "www.example.com",
3980 VerificationMethod::Http,
3981 300,
3982 )
3983 .await
3984 .unwrap();
3985 store
3986 .mark_domain_verified(
3987 ProjectRef::DEFAULT,
3988 &SiteName::new("blog"),
3989 "www.example.com",
3990 )
3991 .await
3992 .unwrap();
3993 let config = store
3994 .attach_verified_domain(
3995 ProjectRef::DEFAULT,
3996 &SiteName::new("blog"),
3997 "www.example.com",
3998 )
3999 .await
4000 .unwrap();
4001 assert_eq!(config.domains.primary.as_deref(), Some("example.com"));
4002 assert_eq!(config.domains.aliases, vec!["www.example.com".to_string()]);
4003
4004 store
4006 .start_domain_verification(
4007 ProjectRef::DEFAULT,
4008 &SiteName::new("blog"),
4009 "*.example.com",
4010 VerificationMethod::Dns,
4011 400,
4012 )
4013 .await
4014 .unwrap();
4015 assert!(store
4017 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4018 .await
4019 .unwrap());
4020 let config = store
4021 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4022 .await
4023 .unwrap();
4024 assert_eq!(config.domains.wildcards, vec!["*.example.com".to_string()]);
4025
4026 assert_eq!(
4028 store
4029 .list_domain_verifications(ProjectRef::DEFAULT, &SiteName::new("blog"))
4030 .await
4031 .unwrap()
4032 .len(),
4033 2
4034 );
4035 assert!(store
4036 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4037 .await
4038 .unwrap());
4039 assert!(!store
4040 .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4041 .await
4042 .unwrap());
4043 }
4044
4045 #[tokio::test]
4049 async fn host_cannot_be_hijacked_across_sites() {
4050 use crate::config::{DomainConfig, SiteConfig};
4051 use crate::domain_verify::VerificationMethod;
4052
4053 let store = store();
4054
4055 store
4057 .start_domain_verification(
4058 ProjectRef::DEFAULT,
4059 &SiteName::new("a"),
4060 "shared.example",
4061 VerificationMethod::Http,
4062 100,
4063 )
4064 .await
4065 .unwrap();
4066 store
4067 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4068 .await
4069 .unwrap();
4070 store
4071 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4072 .await
4073 .unwrap();
4074 assert_eq!(
4075 store
4076 .resolve_site_by_host("shared.example")
4077 .await
4078 .unwrap()
4079 .map(|o| o.site)
4080 .as_deref(),
4081 Some("a")
4082 );
4083
4084 store
4088 .start_domain_verification(
4089 ProjectRef::DEFAULT,
4090 &SiteName::new("b"),
4091 "shared.example",
4092 VerificationMethod::Http,
4093 200,
4094 )
4095 .await
4096 .unwrap();
4097 store
4098 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4099 .await
4100 .unwrap();
4101 let err = store
4102 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4103 .await
4104 .expect_err("second site must not hijack an attached host");
4105 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4106
4107 let stolen = SiteConfig {
4110 domains: DomainConfig {
4111 primary: Some("shared.example".into()),
4112 ..Default::default()
4113 },
4114 ..Default::default()
4115 };
4116 let err = store
4117 .set_site_config(ProjectRef::DEFAULT, "b", &stolen)
4118 .await
4119 .expect_err("set_site_config must refuse another site's host");
4120 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4121
4122 assert_eq!(
4124 store
4125 .resolve_site_by_host("shared.example")
4126 .await
4127 .unwrap()
4128 .map(|o| o.site)
4129 .as_deref(),
4130 Some("a")
4131 );
4132
4133 let readd = SiteConfig {
4136 domains: DomainConfig {
4137 primary: Some("shared.example".into()),
4138 aliases: vec!["www.shared.example".into()],
4139 ..Default::default()
4140 },
4141 ..Default::default()
4142 };
4143 store
4144 .set_site_config(ProjectRef::DEFAULT, "a", &readd)
4145 .await
4146 .unwrap();
4147 assert_eq!(
4148 store
4149 .resolve_site_by_host("www.shared.example")
4150 .await
4151 .unwrap()
4152 .map(|o| o.site)
4153 .as_deref(),
4154 Some("a")
4155 );
4156 }
4157
4158 #[tokio::test]
4162 async fn host_uniqueness_is_case_and_dot_insensitive() {
4163 use crate::config::{DomainConfig, SiteConfig};
4164 use crate::domain_verify::VerificationMethod;
4165
4166 let store = store();
4167 store
4169 .start_domain_verification(
4170 ProjectRef::DEFAULT,
4171 &SiteName::new("a"),
4172 "example.com",
4173 VerificationMethod::Http,
4174 100,
4175 )
4176 .await
4177 .unwrap();
4178 store
4179 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4180 .await
4181 .unwrap();
4182 store
4183 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4184 .await
4185 .unwrap();
4186
4187 for variant in ["Example.COM", "example.com.", "EXAMPLE.com."] {
4189 let cfg = SiteConfig {
4190 domains: DomainConfig {
4191 primary: Some(variant.into()),
4192 ..Default::default()
4193 },
4194 ..Default::default()
4195 };
4196 let err = store
4197 .set_site_config(ProjectRef::DEFAULT, "b", &cfg)
4198 .await
4199 .expect_err("variant claim must be refused");
4200 assert!(
4201 matches!(err, DeployError::Conflict(_)),
4202 "variant {variant:?} must Conflict, got {err:?}"
4203 );
4204 }
4205
4206 for h in ["example.com", "Example.com", "EXAMPLE.COM", "example.com."] {
4208 assert_eq!(
4209 store
4210 .resolve_site_by_host(h)
4211 .await
4212 .unwrap()
4213 .map(|o| o.site)
4214 .as_deref(),
4215 Some("a"),
4216 "host {h:?} must resolve to site a"
4217 );
4218 }
4219 }
4220
4221 #[tokio::test]
4225 async fn self_serve_challenge_lookup_matches_pending_http_only() {
4226 use crate::domain_verify::{VerificationMethod, CHALLENGE_TTL_SECS};
4227
4228 let store = store();
4229 let v = store
4230 .start_domain_verification(
4231 ProjectRef::DEFAULT,
4232 &SiteName::new("docs"),
4233 "docs.example",
4234 VerificationMethod::Http,
4235 1_000,
4236 )
4237 .await
4238 .unwrap();
4239
4240 let found = store
4242 .find_pending_http_challenge("docs.example", &v.token, 1_000)
4243 .await
4244 .unwrap();
4245 assert_eq!(
4246 found.as_ref().map(|f| f.token.clone()),
4247 Some(v.token.clone())
4248 );
4249 assert!(store
4251 .find_pending_http_challenge("Docs.Example.", &v.token, 1_000)
4252 .await
4253 .unwrap()
4254 .is_some());
4255
4256 assert!(store
4258 .find_pending_http_challenge("docs.example", "not-the-token", 1_000)
4259 .await
4260 .unwrap()
4261 .is_none());
4262 assert!(store
4263 .find_pending_http_challenge("other.example", &v.token, 1_000)
4264 .await
4265 .unwrap()
4266 .is_none());
4267
4268 assert!(store
4270 .find_pending_http_challenge("docs.example", &v.token, 1_000 + CHALLENGE_TTL_SECS + 1)
4271 .await
4272 .unwrap()
4273 .is_none());
4274
4275 let dv = store
4277 .start_domain_verification(
4278 ProjectRef::DEFAULT,
4279 &SiteName::new("dns-site"),
4280 "dns.example",
4281 VerificationMethod::Dns,
4282 1_000,
4283 )
4284 .await
4285 .unwrap();
4286 assert!(store
4287 .find_pending_http_challenge("dns.example", &dv.token, 1_000)
4288 .await
4289 .unwrap()
4290 .is_none());
4291 }
4292
4293 #[tokio::test]
4297 async fn wildcard_requires_dns_and_stale_index_is_safe() {
4298 use crate::domain_verify::VerificationMethod;
4299
4300 let store = store();
4301
4302 let http = store
4304 .start_domain_verification(
4305 ProjectRef::DEFAULT,
4306 &SiteName::new("s"),
4307 "*.example.com",
4308 VerificationMethod::Http,
4309 100,
4310 )
4311 .await
4312 .unwrap();
4313 store
4314 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4315 .await
4316 .unwrap();
4317 let err = store
4318 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4319 .await
4320 .expect_err("wildcard with only HTTP proof must be refused");
4321 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4322
4323 store
4327 .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4328 .await
4329 .unwrap();
4330 assert!(store
4332 .find_pending_http_challenge("example.com", &http.token, 100)
4333 .await
4334 .unwrap()
4335 .is_none());
4336 store
4337 .start_domain_verification(
4338 ProjectRef::DEFAULT,
4339 &SiteName::new("s"),
4340 "*.example.com",
4341 VerificationMethod::Dns,
4342 200,
4343 )
4344 .await
4345 .unwrap();
4346 store
4347 .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4348 .await
4349 .unwrap();
4350 let cfg = store
4351 .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
4352 .await
4353 .unwrap();
4354 assert_eq!(cfg.domains.wildcards, vec!["*.example.com".to_string()]);
4355
4356 let h2 = store
4360 .start_domain_verification(
4361 ProjectRef::DEFAULT,
4362 &SiteName::new("s2"),
4363 "host.example",
4364 VerificationMethod::Http,
4365 300,
4366 )
4367 .await
4368 .unwrap();
4369 assert!(store
4370 .find_pending_http_challenge("host.example", &h2.token, 300)
4371 .await
4372 .unwrap()
4373 .is_some());
4374 store
4375 .start_domain_verification(
4376 ProjectRef::DEFAULT,
4377 &SiteName::new("s2"),
4378 "host.example",
4379 VerificationMethod::Dns,
4380 300,
4381 )
4382 .await
4383 .unwrap();
4384 assert!(
4385 store
4386 .find_pending_http_challenge("host.example", &h2.token, 300)
4387 .await
4388 .unwrap()
4389 .is_none(),
4390 "a stale HTTP index must not serve a token whose record is now DNS"
4391 );
4392 }
4393
4394 #[tokio::test]
4395 async fn pending_verifications_are_capped_per_site() {
4396 let store = store();
4397 for i in 0..64 {
4399 store
4400 .start_domain_verification(
4401 ProjectRef::DEFAULT,
4402 &SiteName::new("site"),
4403 &format!("h{i}.example"),
4404 VerificationMethod::Http,
4405 100,
4406 )
4407 .await
4408 .unwrap();
4409 }
4410 let err = store
4412 .start_domain_verification(
4413 ProjectRef::DEFAULT,
4414 &SiteName::new("site"),
4415 "overflow.example",
4416 VerificationMethod::Http,
4417 100,
4418 )
4419 .await
4420 .expect_err("the 65th pending host must be rejected");
4421 assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4422 store
4424 .start_domain_verification(
4425 ProjectRef::DEFAULT,
4426 &SiteName::new("site"),
4427 "h0.example",
4428 VerificationMethod::Http,
4429 100,
4430 )
4431 .await
4432 .expect("re-running an existing challenge is not capped");
4433 store
4435 .start_domain_verification(
4436 ProjectRef::DEFAULT,
4437 &SiteName::new("other"),
4438 "fresh.example",
4439 VerificationMethod::Http,
4440 100,
4441 )
4442 .await
4443 .expect("a different site is unaffected");
4444 }
4445
4446 fn store() -> DeployStore {
4447 use crate::kv::MemoryKv;
4448 DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
4449 }
4450
4451 #[tokio::test]
4452 async fn default_project_is_visible_on_a_fresh_store_and_ensure_is_idempotent() {
4453 let s = store();
4454 let default = crate::project::DEFAULT_PROJECT;
4455
4456 assert!(
4458 s.get_project(default).await.unwrap().is_some(),
4459 "`project show default` must never 404, even on a fresh store"
4460 );
4461 let listed = s.list_projects().await.unwrap();
4462 assert_eq!(
4463 listed.iter().filter(|p| p.name == default).count(),
4464 1,
4465 "`project ls` must show exactly one `default` on a fresh store"
4466 );
4467 assert!(s.get_project("nope").await.unwrap().is_none());
4469
4470 assert!(s.project_exists(default).await.unwrap());
4473 assert!(!s.project_exists("nope").await.unwrap());
4474
4475 assert!(
4477 s.ensure_default_project().await.unwrap(),
4478 "first ensure creates"
4479 );
4480 assert!(
4481 !s.ensure_default_project().await.unwrap(),
4482 "second ensure is idempotent (presence-checked)"
4483 );
4484
4485 let listed = s.list_projects().await.unwrap();
4487 assert_eq!(
4488 listed.iter().filter(|p| p.name == default).count(),
4489 1,
4490 "materializing `default` must not duplicate it in the listing"
4491 );
4492 assert_eq!(s.get_project(default).await.unwrap().unwrap().name, default);
4493 }
4494
4495 #[tokio::test]
4496 async fn daemon_config_store_round_trips_and_rolls_back() {
4497 use crate::daemon_config::DaemonConfig;
4498 let s = store();
4499 assert!(s.get_daemon_config().await.unwrap().is_none());
4501 assert!(s.daemon_config_generation().await.unwrap().is_none());
4502
4503 let g1cfg = DaemonConfig {
4505 default_site: Some("one".into()),
4506 ..Default::default()
4507 };
4508 let g1 = s.set_daemon_config(&g1cfg).await.unwrap();
4509 assert_eq!(
4510 s.daemon_config_generation().await.unwrap().as_deref(),
4511 Some(g1.as_str())
4512 );
4513 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4514 assert!(s.daemon_config_history().await.unwrap().is_empty());
4515
4516 let g2cfg = DaemonConfig {
4518 default_site: Some("two".into()),
4519 ..Default::default()
4520 };
4521 let g2 = s.set_daemon_config(&g2cfg).await.unwrap();
4522 assert_ne!(g1, g2);
4523 assert_eq!(s.daemon_config_history().await.unwrap(), vec![g1.clone()]);
4524
4525 let rolled = s.rollback_daemon_config().await.unwrap();
4527 assert_eq!(rolled.as_deref(), Some(g1.as_str()));
4528 assert_eq!(
4529 s.daemon_config_generation().await.unwrap().as_deref(),
4530 Some(g1.as_str())
4531 );
4532 assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
4533 assert!(s.rollback_daemon_config().await.unwrap().is_none());
4535 }
4536
4537 #[tokio::test]
4538 async fn compute_store_round_trips() {
4539 use crate::compute::{
4540 ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
4541 };
4542 let s = store();
4543 let spec = ComputeSpec {
4544 version: crate::SCHEMA_VERSION,
4545 root: RootSource::Rootfs("r".repeat(64)),
4546 kernel: "k".repeat(64),
4547 kernel_cmdline: None,
4548 vcpus: 1,
4549 mem_mib: 256,
4550 entrypoint: vec!["/app".into()],
4551 env: Default::default(),
4552 port: 8080,
4553 restart: RestartPolicy::Always,
4554 scale_to_zero: false,
4555 volumes: vec![],
4556 writable_root: false,
4557 cap_add: Vec::new(),
4558 user: None,
4559 isolation: Default::default(),
4560 prefer_backend: None,
4561 bindings: vec![],
4562 };
4563 let hash = s.put_compute_spec(&spec).await.unwrap();
4565 assert_eq!(hash, spec.id());
4566 assert_eq!(s.get_compute_spec(&hash).await.unwrap(), Some(spec));
4567 assert!(s.get_compute_spec("deadbeef").await.unwrap().is_none());
4568
4569 let workload = ComputeWorkload {
4571 version: crate::SCHEMA_VERSION,
4572 name: "api".into(),
4573 active: hash.clone(),
4574 replicas: 3,
4575 placement: PlacementConstraints::default(),
4576 };
4577 s.set_compute_workload(ProjectRef::DEFAULT, &workload)
4578 .await
4579 .unwrap();
4580 assert_eq!(
4581 s.get_compute_workload(ProjectRef::DEFAULT, "api")
4582 .await
4583 .unwrap(),
4584 Some(workload)
4585 );
4586 assert_eq!(
4587 s.list_compute_workloads(ProjectRef::DEFAULT)
4588 .await
4589 .unwrap()
4590 .len(),
4591 1
4592 );
4593 assert!(s
4594 .delete_compute_workload(ProjectRef::DEFAULT, "api")
4595 .await
4596 .unwrap());
4597 assert!(!s
4598 .delete_compute_workload(ProjectRef::DEFAULT, "api")
4599 .await
4600 .unwrap());
4601 assert!(s
4602 .list_compute_workloads(ProjectRef::DEFAULT)
4603 .await
4604 .unwrap()
4605 .is_empty());
4606 }
4607
4608 fn empty_manifest(clean_urls: bool) -> Manifest {
4611 Manifest {
4612 config: DeployConfig {
4613 clean_urls,
4614 ..DeployConfig::default()
4615 },
4616 ..Default::default()
4617 }
4618 }
4619
4620 #[tokio::test]
4621 async fn deploy_meta_records_sizes_and_merges_provenance() {
4622 let store = store();
4623 let mut manifest = Manifest::default();
4624 manifest.files.insert("index.html".into(), {
4625 let mut e = entry("aa");
4626 e.size = 10;
4627 e
4628 });
4629 manifest.files.insert("app.js".into(), {
4630 let mut e = entry("bb");
4631 e.size = 32;
4632 e
4633 });
4634
4635 let id = store
4637 .put_manifest_with(
4638 &manifest,
4639 DeployMetaInput {
4640 source: Some("abc123".into()),
4641 message: Some("first".into()),
4642 ..Default::default()
4643 },
4644 )
4645 .await
4646 .unwrap();
4647 let meta = store.get_meta(&id).await.unwrap().unwrap();
4648 assert_eq!(meta.file_count, 2);
4649 assert_eq!(meta.total_size, 42);
4650 assert_eq!(meta.source.as_deref(), Some("abc123"));
4651 let created = meta.created_at;
4652
4653 store
4655 .put_manifest_with(&manifest, DeployMetaInput::default())
4656 .await
4657 .unwrap();
4658 let meta = store.get_meta(&id).await.unwrap().unwrap();
4659 assert_eq!(meta.created_at, created);
4660 assert_eq!(meta.source.as_deref(), Some("abc123"));
4661 assert_eq!(meta.message.as_deref(), Some("first"));
4662 }
4663
4664 #[tokio::test]
4665 async fn aliases_round_trip_and_guard_completeness() {
4666 let store = store();
4667 let manifest = empty_manifest(false);
4668 let id = store.put_manifest(&manifest).await.unwrap();
4669
4670 assert!(matches!(
4672 store
4673 .set_alias(ProjectRef::DEFAULT, "blog", "staging", "deadbeef")
4674 .await,
4675 Err(DeployError::NotFound(_))
4676 ));
4677
4678 store
4679 .set_alias(ProjectRef::DEFAULT, "blog", "staging", &id)
4680 .await
4681 .unwrap();
4682 assert_eq!(
4683 store
4684 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4685 .await
4686 .unwrap(),
4687 Some(id.clone())
4688 );
4689 let aliases = store
4690 .list_aliases(ProjectRef::DEFAULT, "blog")
4691 .await
4692 .unwrap();
4693 assert_eq!(aliases.get("staging"), Some(&id));
4694
4695 assert!(store
4696 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4697 .await
4698 .unwrap());
4699 assert!(!store
4700 .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
4701 .await
4702 .unwrap());
4703 assert_eq!(
4704 store
4705 .get_alias(ProjectRef::DEFAULT, "blog", "staging")
4706 .await
4707 .unwrap(),
4708 None
4709 );
4710 }
4711
4712 #[tokio::test]
4713 async fn retention_keep_last_collects_older_history() {
4714 let store = store();
4715 let m1 = empty_manifest(false);
4717 let m2 = empty_manifest(true);
4718 let mut m3 = empty_manifest(true);
4719 m3.config.trailing_slash = crate::config::TrailingSlash::Always;
4720 let id1 = store.put_manifest(&m1).await.unwrap();
4721 let id2 = store.put_manifest(&m2).await.unwrap();
4722 let id3 = store.put_manifest(&m3).await.unwrap();
4723 store
4724 .activate(ProjectRef::DEFAULT, "blog", &id1)
4725 .await
4726 .unwrap();
4727 store
4728 .activate(ProjectRef::DEFAULT, "blog", &id2)
4729 .await
4730 .unwrap();
4731 store
4732 .activate(ProjectRef::DEFAULT, "blog", &id3)
4733 .await
4734 .unwrap();
4735
4736 let report = store.collect_garbage(false).await.unwrap();
4738 assert_eq!(report.manifests_removed, 0);
4739
4740 store
4743 .set_alias(ProjectRef::DEFAULT, "blog", "pinned", &id1)
4744 .await
4745 .unwrap();
4746 let report = store
4747 .collect_garbage_with(
4748 false,
4749 GcOptions {
4750 keep_last: Some(1),
4751 ..Default::default()
4752 },
4753 )
4754 .await
4755 .unwrap();
4756 assert_eq!(report.manifests_removed, 1); }
4758
4759 #[tokio::test]
4760 async fn grace_window_protects_in_flight_manifest() {
4761 let store = store();
4762 let id = store.put_manifest(&empty_manifest(false)).await.unwrap();
4764 assert!(store.get_manifest(&id).await.unwrap().is_some());
4765
4766 let report = store
4768 .collect_garbage_with(
4769 false,
4770 GcOptions {
4771 grace_secs: 3600,
4772 ..Default::default()
4773 },
4774 )
4775 .await
4776 .unwrap();
4777 assert_eq!(report.manifests_removed, 0);
4778
4779 let report = store.collect_garbage(false).await.unwrap();
4781 assert_eq!(report.manifests_removed, 1);
4782 }
4783
4784 #[tokio::test]
4785 async fn resolve_manifest_id_exact_prefix_and_missing() {
4786 let store = store();
4787 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4788
4789 assert_eq!(
4791 store.resolve_manifest_id(&id).await.unwrap().as_deref(),
4792 Some(id.as_str())
4793 );
4794 assert_eq!(
4796 store
4797 .resolve_manifest_id(&id[..16])
4798 .await
4799 .unwrap()
4800 .as_deref(),
4801 Some(id.as_str())
4802 );
4803 assert!(store
4805 .resolve_manifest_id("ffffffffffffffff")
4806 .await
4807 .unwrap()
4808 .is_none());
4809 }
4810
4811 #[tokio::test]
4812 async fn delete_site_removes_config_routing_and_aliases() {
4813 let store = store();
4814 let mut cfg = SiteConfig::default();
4815 cfg.domains.primary = Some("blog.example".into());
4816 cfg.domains.wildcards = vec!["*.preview.blog.example".into()];
4817 store
4818 .set_site_config(ProjectRef::DEFAULT, "blog", &cfg)
4819 .await
4820 .unwrap();
4821 let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
4823 store
4824 .set_alias(ProjectRef::DEFAULT, "blog", "stable", &id)
4825 .await
4826 .unwrap();
4827
4828 assert!(store
4830 .get_site_config(ProjectRef::DEFAULT, "blog")
4831 .await
4832 .unwrap()
4833 .is_some());
4834 assert_eq!(
4835 store
4836 .resolve_site_by_host("blog.example")
4837 .await
4838 .unwrap()
4839 .map(|o| o.site)
4840 .as_deref(),
4841 Some("blog")
4842 );
4843
4844 store
4845 .delete_site(ProjectRef::DEFAULT, "blog")
4846 .await
4847 .unwrap();
4848
4849 assert!(store
4851 .get_site_config(ProjectRef::DEFAULT, "blog")
4852 .await
4853 .unwrap()
4854 .is_none());
4855 assert!(store
4856 .resolve_site_by_host("blog.example")
4857 .await
4858 .unwrap()
4859 .is_none());
4860 assert!(store
4861 .list_aliases(ProjectRef::DEFAULT, "blog")
4862 .await
4863 .unwrap()
4864 .is_empty());
4865
4866 store
4868 .delete_site(ProjectRef::DEFAULT, "blog")
4869 .await
4870 .unwrap();
4871 }
4872
4873 #[tokio::test]
4874 async fn gc_blob_reachability_is_a_cross_project_union() {
4875 use crate::kv::MemoryKv;
4876 let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
4878
4879 let shared = sha256_hex(b"shared-blob");
4883 let keep = sha256_hex(b"keep-blob");
4884 let dead = sha256_hex(b"dead-blob");
4885 store
4886 .put_blob(&shared, once_bytes(b"shared-blob"))
4887 .await
4888 .unwrap();
4889 store
4890 .put_blob(&keep, once_bytes(b"keep-blob"))
4891 .await
4892 .unwrap();
4893 store
4894 .put_blob(&dead, once_bytes(b"dead-blob"))
4895 .await
4896 .unwrap();
4897
4898 let m_shared = manifest_with(&[("index.html", &shared)]);
4899 let m_keep = manifest_with(&[("index.html", &keep)]);
4900 let m_dead = manifest_with(&[("old.html", &dead)]);
4901 let id_shared = store.put_manifest(&m_shared).await.unwrap();
4902 let id_keep = store.put_manifest(&m_keep).await.unwrap();
4903 let id_dead = store.put_manifest(&m_dead).await.unwrap();
4904
4905 let acme = ProjectRef::new("acme");
4907 store.activate(acme, "site", &id_shared).await.unwrap();
4908 let shop = ProjectRef::new("shop");
4912 store.activate(shop, "site", &id_shared).await.unwrap();
4913 store.activate(shop, "site", &id_keep).await.unwrap();
4914 let report = store
4917 .collect_garbage_with(
4918 true,
4919 GcOptions {
4920 keep_last: Some(1),
4921 ..Default::default()
4922 },
4923 )
4924 .await
4925 .unwrap();
4926
4927 assert!(store.get_manifest(&id_dead).await.unwrap().is_none());
4929 assert!(
4930 !store.has_blob(&dead).await.unwrap(),
4931 "dead blob is reclaimed"
4932 );
4933 assert_eq!(report.manifests_removed, 1, "only the dead manifest goes");
4934
4935 assert!(
4938 store.get_manifest(&id_shared).await.unwrap().is_some(),
4939 "shared manifest kept by acme's reference"
4940 );
4941 assert!(
4942 store.has_blob(&shared).await.unwrap(),
4943 "shared blob kept — reachability is the union across projects"
4944 );
4945 assert!(store.has_blob(&keep).await.unwrap());
4947 assert_eq!(
4948 store.current_id(acme, "site").await.unwrap().as_deref(),
4949 Some(id_shared.as_str())
4950 );
4951 }
4952
4953 #[tokio::test]
4954 async fn same_site_name_in_two_projects_is_isolated() {
4955 use crate::kv::MemoryKv;
4956 let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4957 let acme = ProjectRef::new("acme");
4958 let shop = ProjectRef::new("shop");
4959
4960 let id_a = store.put_manifest(&empty_manifest(false)).await.unwrap();
4962 let id_b = store.put_manifest(&empty_manifest(true)).await.unwrap();
4963 store.activate(acme, "www", &id_a).await.unwrap();
4964 store.activate(shop, "www", &id_b).await.unwrap();
4965
4966 assert_eq!(
4968 store.current_id(acme, "www").await.unwrap().as_deref(),
4969 Some(id_a.as_str())
4970 );
4971 assert_eq!(
4972 store.current_id(shop, "www").await.unwrap().as_deref(),
4973 Some(id_b.as_str())
4974 );
4975 assert_ne!(id_a, id_b);
4976
4977 let mut cfg_a = SiteConfig::default();
4979 cfg_a.domains.primary = Some("acme.example".into());
4980 store.set_site_config(acme, "www", &cfg_a).await.unwrap();
4981 let mut cfg_b = SiteConfig::default();
4982 cfg_b.domains.primary = Some("shop.example".into());
4983 store.set_site_config(shop, "www", &cfg_b).await.unwrap();
4984 store
4985 .set_alias(acme, "www", "staging", &id_a)
4986 .await
4987 .unwrap();
4988
4989 assert_eq!(
4990 store
4991 .get_site_config(acme, "www")
4992 .await
4993 .unwrap()
4994 .unwrap()
4995 .domains
4996 .primary
4997 .as_deref(),
4998 Some("acme.example")
4999 );
5000 assert_eq!(
5001 store
5002 .get_site_config(shop, "www")
5003 .await
5004 .unwrap()
5005 .unwrap()
5006 .domains
5007 .primary
5008 .as_deref(),
5009 Some("shop.example")
5010 );
5011 assert!(store
5013 .get_alias(acme, "www", "staging")
5014 .await
5015 .unwrap()
5016 .is_some());
5017 assert!(store
5018 .get_alias(shop, "www", "staging")
5019 .await
5020 .unwrap()
5021 .is_none());
5022 assert!(store.list_aliases(shop, "www").await.unwrap().is_empty());
5023
5024 assert_eq!(
5026 store.resolve_site_by_host("acme.example").await.unwrap(),
5027 Some(DomainOwner::new("acme", "www"))
5028 );
5029 assert_eq!(
5030 store.resolve_site_by_host("shop.example").await.unwrap(),
5031 Some(DomainOwner::new("shop", "www"))
5032 );
5033 store.delete_site(acme, "www").await.unwrap();
5034 assert!(store.get_site_config(acme, "www").await.unwrap().is_none());
5035 assert!(
5036 store.get_site_config(shop, "www").await.unwrap().is_some(),
5037 "deleting acme/www must not touch shop/www"
5038 );
5039 assert!(store.current_id(shop, "www").await.unwrap().is_some());
5040 }
5041
5042 #[tokio::test]
5043 async fn project_entity_crud_and_delete_guard() {
5044 use crate::project::Project;
5045 let store = store();
5046 let acme = Project {
5047 version: crate::SCHEMA_VERSION,
5048 name: "acme".into(),
5049 created_at: 1,
5050 meta: Default::default(),
5051 config: Default::default(),
5052 secrets_ref: None,
5053 };
5054 let hash = store.put_project(&acme).await.unwrap();
5056 assert_eq!(hash, acme.id());
5057 assert_eq!(store.get_project("acme").await.unwrap(), Some(acme.clone()));
5058 assert!(store.get_project("ghost").await.unwrap().is_none());
5059 let names: Vec<String> = store
5061 .list_projects()
5062 .await
5063 .unwrap()
5064 .into_iter()
5065 .map(|p| p.name)
5066 .collect();
5067 assert_eq!(names, vec!["acme".to_string(), "default".to_string()]);
5068
5069 store
5071 .set_site_config(ProjectRef::new("acme"), "www", &SiteConfig::default())
5072 .await
5073 .unwrap();
5074 assert!(matches!(
5075 store.delete_project("acme").await,
5076 Err(DeployError::Conflict(_))
5077 ));
5078 store
5080 .delete_site(ProjectRef::new("acme"), "www")
5081 .await
5082 .unwrap();
5083 assert!(store.delete_project("acme").await.unwrap());
5084 assert!(store.get_project("acme").await.unwrap().is_none());
5085
5086 assert!(matches!(
5088 store.delete_project("default").await,
5089 Err(DeployError::Conflict(_))
5090 ));
5091 }
5092
5093 #[tokio::test]
5094 async fn open_blob_cached_caches_small_and_streams_large() {
5095 use crate::kv::MemoryKv;
5096 let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
5097
5098 let small = b"hello, static hot path";
5101 let small_hash = sha256_hex(small);
5102 store
5103 .put_blob(&small_hash, once_bytes(small))
5104 .await
5105 .unwrap();
5106 match store
5107 .open_blob_cached(&small_hash, small.len() as u64)
5108 .await
5109 .unwrap()
5110 {
5111 BlobBody::Cached(bytes) => assert_eq!(&bytes[..], small),
5112 BlobBody::Stream(_) => panic!("small blob should be cached, not streamed"),
5113 }
5114 {
5115 let cache = store.blob_body_cache.read().unwrap();
5116 assert!(cache.map.contains_key(&small_hash));
5117 assert_eq!(cache.bytes, small.len());
5118 }
5119 assert!(matches!(
5120 store
5121 .open_blob_cached(&small_hash, small.len() as u64)
5122 .await
5123 .unwrap(),
5124 BlobBody::Cached(_)
5125 ));
5126
5127 let large = vec![7u8; SMALL_BLOB_CACHE_MAX as usize + 1];
5129 let large_hash = sha256_hex(&large);
5130 let put_body = {
5131 let large = large.clone();
5132 futures::stream::once(async move { Ok(bytes::Bytes::from(large)) }).boxed()
5133 };
5134 store.put_blob(&large_hash, put_body).await.unwrap();
5135 match store
5136 .open_blob_cached(&large_hash, large.len() as u64)
5137 .await
5138 .unwrap()
5139 {
5140 BlobBody::Stream(object) => {
5141 let mut body = object.body;
5142 let mut got = Vec::new();
5143 while let Some(chunk) = body.next().await {
5144 got.extend_from_slice(&chunk.unwrap());
5145 }
5146 assert_eq!(got, large);
5147 }
5148 BlobBody::Cached(_) => panic!("large blob must stream, not cache"),
5149 }
5150 assert!(!store
5151 .blob_body_cache
5152 .read()
5153 .unwrap()
5154 .map
5155 .contains_key(&large_hash));
5156 }
5157}