1use super::*;
2
3#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
4pub(crate) struct DeployLock {
5 version: u32,
9 #[serde(default)]
10 pub(crate) environments: BTreeMap<String, Environment>,
11}
12
13pub(crate) type Expects = BTreeMap<String, BTreeMap<String, String>>;
16
17fn lock_version() -> u32 {
18 1
19}
20
21#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub(crate) struct Environment {
23 #[serde(default)]
25 pub(crate) kv: BTreeMap<String, KvNamespace>,
26 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
35 pub(crate) kv_instances: BTreeMap<String, BTreeMap<String, KvNamespace>>,
36 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45 pub(crate) r2_instances: BTreeMap<String, BTreeMap<String, R2Bucket>>,
46 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55 pub(crate) ratelimit_instances: BTreeMap<String, BTreeMap<String, RateLimitNamespace>>,
56 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
63 workers: BTreeMap<String, WorkerRecord>,
64 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
76 queues: BTreeSet<String>,
77 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 deleted_classes: BTreeMap<String, BTreeSet<String>>,
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95 renamed_classes: BTreeMap<String, BTreeMap<String, String>>,
96}
97
98#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
99pub(crate) struct KvNamespace {
100 pub(crate) id: String,
101}
102
103#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
105pub(crate) struct RateLimitNamespace {
106 pub(crate) namespace_id: String,
107}
108
109pub(crate) const FIRST_RATELIMIT_NAMESPACE: u64 = 1000;
112
113pub(crate) const RATELIMIT_NAMESPACE_SPAN: u64 = 1_000_000_000;
116
117pub(crate) fn ratelimit_seed(environment: &str, worker: &str, binding: &str) -> u64 {
125 use sha2::{Digest, Sha256};
126 let digest = Sha256::digest(format!("{environment}\0{worker}\0{binding}").as_bytes());
127 let n = u64::from_be_bytes(digest[..8].try_into().expect("8 bytes"));
128 FIRST_RATELIMIT_NAMESPACE + n % RATELIMIT_NAMESPACE_SPAN
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
133pub(crate) struct R2Bucket {
134 pub(crate) name: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub(crate) jurisdiction: Option<String>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub(crate) superseded: Vec<R2Past>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub(crate) struct R2Past {
149 pub(crate) name: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub(crate) jurisdiction: Option<String>,
152}
153
154#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
157struct WorkerRecord {
158 deployed: bool,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
178 contracts: Option<BTreeMap<String, String>>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
195 expects: Option<Expects>,
196 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
203 assets: BTreeSet<String>,
204 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211 foreign: BTreeMap<String, BTreeMap<String, String>>,
212}
213
214impl DeployLock {
215 pub(crate) fn is_deployed(&self, environment: &str, worker: &str) -> bool {
216 self.environments
217 .get(environment)
218 .and_then(|env| env.workers.get(worker))
219 .is_some_and(|record| record.deployed)
220 }
221
222 pub(crate) fn deployed_environments(&self, worker: &str) -> Vec<String> {
226 self.environments
227 .iter()
228 .filter(|(env, _)| self.is_deployed(env, worker))
229 .map(|(env, _)| env.clone())
230 .collect()
231 }
232
233 pub(crate) fn has_deleted_class(&self, environment: &str, worker: &str, class: &str) -> bool {
236 self.environments
237 .get(environment)
238 .and_then(|env| env.deleted_classes.get(worker))
239 .is_some_and(|classes| classes.contains(class))
240 }
241
242 pub(crate) fn record_deleted_classes(
245 &mut self,
246 environment: &str,
247 worker: &str,
248 classes: BTreeSet<String>,
249 ) {
250 let env = self
251 .environments
252 .entry(environment.to_string())
253 .or_default();
254 if classes.is_empty() {
255 env.deleted_classes.remove(worker);
256 } else {
257 env.deleted_classes.insert(worker.to_string(), classes);
258 }
259 }
260
261 pub(crate) fn has_renamed_class(
264 &self,
265 environment: &str,
266 worker: &str,
267 from: &str,
268 to: &str,
269 ) -> bool {
270 self.environments
271 .get(environment)
272 .and_then(|env| env.renamed_classes.get(worker))
273 .and_then(|renames| renames.get(from))
274 .is_some_and(|recorded| recorded == to)
275 }
276
277 pub(crate) fn record_renamed_classes(
280 &mut self,
281 environment: &str,
282 worker: &str,
283 renames: BTreeMap<String, String>,
284 ) {
285 let env = self
286 .environments
287 .entry(environment.to_string())
288 .or_default();
289 if renames.is_empty() {
290 env.renamed_classes.remove(worker);
291 } else {
292 env.renamed_classes.insert(worker.to_string(), renames);
293 }
294 }
295
296 pub(crate) fn record_deployed(
297 &mut self,
298 environment: &str,
299 worker: &str,
300 contracts: Option<BTreeMap<String, String>>,
301 ) {
302 self.environments
303 .entry(environment.to_string())
304 .or_default()
305 .workers
306 .insert(
307 worker.to_string(),
308 WorkerRecord {
309 deployed: true,
310 contracts,
311 expects: None,
312 assets: BTreeSet::new(),
313 foreign: BTreeMap::new(),
314 },
315 );
316 }
317
318 pub(crate) fn record_assets(
322 &mut self,
323 environment: &str,
324 worker: &str,
325 assets: BTreeSet<String>,
326 ) {
327 if let Some(record) = self
328 .environments
329 .get_mut(environment)
330 .and_then(|env| env.workers.get_mut(worker))
331 {
332 record.assets = assets;
333 }
334 }
335
336 pub(crate) fn record_foreign(
341 &mut self,
342 environment: &str,
343 worker: &str,
344 foreign: BTreeMap<String, BTreeMap<String, String>>,
345 ) {
346 if let Some(record) = self
347 .environments
348 .get_mut(environment)
349 .and_then(|env| env.workers.get_mut(worker))
350 {
351 record.foreign = foreign;
352 }
353 }
354
355 pub(crate) fn record_expects(&mut self, environment: &str, worker: &str, expects: Expects) {
360 if let Some(record) = self
361 .environments
362 .get_mut(environment)
363 .and_then(|env| env.workers.get_mut(worker))
364 {
365 record.expects = Some(expects);
366 }
367 }
368
369 pub(crate) fn live_expects(
373 &self,
374 environment: &str,
375 ) -> impl Iterator<Item = (&str, Option<&Expects>)> {
376 self.environments
377 .get(environment)
378 .into_iter()
379 .flat_map(|env| env.workers.iter())
380 .filter(|(_, record)| record.deployed)
381 .map(|(worker, record)| (worker.as_str(), record.expects.as_ref()))
382 }
383
384 #[cfg(test)]
387 pub(crate) fn live_foreign(
388 &self,
389 environment: &str,
390 worker: &str,
391 ) -> Option<&BTreeMap<String, BTreeMap<String, String>>> {
392 self.environments
393 .get(environment)
394 .and_then(|env| env.workers.get(worker))
395 .map(|record| &record.foreign)
396 }
397
398 pub(crate) fn live_contracts(
404 &self,
405 environment: &str,
406 worker: &str,
407 ) -> Option<&BTreeMap<String, String>> {
408 self.environments
409 .get(environment)
410 .and_then(|env| env.workers.get(worker))
411 .and_then(|record| record.contracts.as_ref())
412 }
413
414 pub(crate) fn record_kv(&mut self, environment: &str, worker: &str, binding: &str, id: &str) {
418 let env = self
419 .environments
420 .entry(environment.to_string())
421 .or_default();
422 let namespace = KvNamespace { id: id.to_string() };
423 if is_bare_kv(binding) {
424 env.kv.insert(worker.to_string(), namespace);
425 } else {
426 env.kv_instances
427 .entry(worker.to_string())
428 .or_default()
429 .insert(binding.to_string(), namespace);
430 }
431 }
432
433 pub(crate) fn record_r2(
436 &mut self,
437 environment: &str,
438 worker: &str,
439 binding: &str,
440 name: &str,
441 jurisdiction: Option<&str>,
442 ) {
443 let instances = self
444 .environments
445 .entry(environment.to_string())
446 .or_default()
447 .r2_instances
448 .entry(worker.to_string())
449 .or_default();
450 let mut superseded = Vec::new();
452 if let Some(old) = instances.remove(binding) {
453 superseded = old.superseded;
454 if old.name != name || old.jurisdiction.as_deref() != jurisdiction {
455 superseded.push(R2Past {
456 name: old.name,
457 jurisdiction: old.jurisdiction,
458 });
459 }
460 }
461 instances.insert(
462 binding.to_string(),
463 R2Bucket {
464 name: name.to_string(),
465 jurisdiction: jurisdiction.map(str::to_string),
466 superseded,
467 },
468 );
469 }
470
471 pub(crate) fn allocate_ratelimit(
477 &mut self,
478 environment: &str,
479 worker: &str,
480 binding: &str,
481 ) -> (String, bool) {
482 if let Some(id) = recorded_ratelimit(self, worker, binding, environment) {
483 return (id.to_string(), false);
484 }
485 let taken: BTreeSet<&str> = self
486 .environments
487 .values()
488 .flat_map(|env| env.ratelimit_instances.values())
489 .flat_map(|instances| instances.values())
490 .map(|r| r.namespace_id.as_str())
491 .collect();
492 let seed = ratelimit_seed(environment, worker, binding) - FIRST_RATELIMIT_NAMESPACE;
493 let id = (0..RATELIMIT_NAMESPACE_SPAN)
494 .map(|k| {
495 (FIRST_RATELIMIT_NAMESPACE + (seed + k) % RATELIMIT_NAMESPACE_SPAN).to_string()
496 })
497 .find(|n| !taken.contains(n.as_str()))
498 .expect("an unused id");
499 self.environments
500 .entry(environment.to_string())
501 .or_default()
502 .ratelimit_instances
503 .entry(worker.to_string())
504 .or_default()
505 .insert(
506 binding.to_string(),
507 RateLimitNamespace {
508 namespace_id: id.clone(),
509 },
510 );
511 (id, true)
512 }
513
514 pub(crate) fn forget_undeclared_ratelimits(
519 &mut self,
520 environment: &str,
521 worker: &str,
522 declared: &[String],
523 ) -> bool {
524 let Some(env) = self.environments.get_mut(environment) else {
525 return false;
526 };
527 let Some(instances) = env.ratelimit_instances.get_mut(worker) else {
528 return false;
529 };
530 let before = instances.len();
531 instances.retain(|binding, _| declared.contains(binding));
532 let changed = instances.len() != before;
533 if instances.is_empty() {
534 env.ratelimit_instances.remove(worker);
535 }
536 changed
537 }
538
539 fn forget_kv(&mut self, environment: &str, worker: &str, binding: &str) {
543 let Some(env) = self.environments.get_mut(environment) else {
544 return;
545 };
546 if is_bare_kv(binding) {
547 env.kv.remove(worker);
548 } else if let Some(instances) = env.kv_instances.get_mut(worker) {
549 instances.remove(binding);
550 if instances.is_empty() {
551 env.kv_instances.remove(worker);
552 }
553 }
554 }
555
556 pub(crate) fn has_queue(&self, environment: &str, queue: &str) -> bool {
557 self.environments
558 .get(environment)
559 .is_some_and(|env| env.queues.contains(queue))
560 }
561
562 pub(crate) fn record_queue(&mut self, environment: &str, queue: &str) -> bool {
565 self.environments
566 .entry(environment.to_string())
567 .or_default()
568 .queues
569 .insert(queue.to_string())
570 }
571}
572
573#[derive(Debug, Default, PartialEq, Eq, Serialize)]
586pub(crate) struct Orphans {
587 pub(crate) kv: Vec<OrphanKv>,
588 #[serde(skip_serializing_if = "Vec::is_empty")]
592 pub(crate) r2: Vec<OrphanR2>,
593 pub(crate) workers: Vec<String>,
594 pub(crate) queues: Vec<String>,
595}
596
597#[derive(Debug, PartialEq, Eq, Serialize)]
601pub(crate) struct OrphanKv {
602 pub(crate) worker: String,
603 #[serde(skip_serializing_if = "Option::is_none")]
604 pub(crate) binding: Option<String>,
605}
606
607#[derive(Debug, PartialEq, Eq, Serialize)]
610pub(crate) struct OrphanR2 {
611 pub(crate) worker: String,
612 pub(crate) binding: String,
613 pub(crate) bucket: String,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub(crate) jurisdiction: Option<String>,
617}
618
619impl OrphanKv {
620 pub(crate) fn binding(&self) -> &str {
622 self.binding
623 .as_deref()
624 .unwrap_or(bynk_emit::emitter::wrangler::KV_BINDING_NAME)
625 }
626}
627
628impl Orphans {
629 pub(crate) fn has_prunable(&self) -> bool {
636 !self.kv.is_empty() || !self.queues.is_empty()
637 }
638
639 pub(crate) fn as_named_in(&self, environment: &str) -> Orphans {
646 Orphans {
647 kv: self
648 .kv
649 .iter()
650 .map(|kv| OrphanKv {
651 worker: kv.worker.clone(),
652 binding: kv.binding.clone(),
653 })
654 .collect(),
655 r2: self
656 .r2
657 .iter()
658 .map(|r2| OrphanR2 {
659 worker: r2.worker.clone(),
660 binding: r2.binding.clone(),
661 bucket: r2.bucket.clone(),
662 jurisdiction: r2.jurisdiction.clone(),
663 })
664 .collect(),
665 workers: self
666 .workers
667 .iter()
668 .map(|worker| deployed_worker_name(environment, worker))
669 .collect(),
670 queues: self
671 .queues
672 .iter()
673 .map(|queue| env_qualify(environment, queue))
674 .collect(),
675 }
676 }
677}
678
679pub(crate) fn find_orphans(
693 lock: &DeployLock,
694 environment: &str,
695 resources: &BTreeMap<String, Resources>,
696) -> Orphans {
697 let Some(env) = lock.environments.get(environment) else {
698 return Orphans::default();
699 };
700 let live_queues: BTreeSet<&str> = resources
701 .values()
702 .flat_map(|r| r.queues.iter().map(String::as_str))
703 .collect();
704 let instance_orphans = env.kv_instances.iter().flat_map(|(worker, instances)| {
707 instances
708 .keys()
709 .filter(move |binding| {
710 resources
711 .get(worker)
712 .is_none_or(|r| !r.kv_instances.contains(binding))
713 })
714 .map(move |binding| OrphanKv {
715 worker: worker.clone(),
716 binding: Some(binding.clone()),
717 })
718 });
719 let r2_orphans = env
721 .r2_instances
722 .iter()
723 .flat_map(|(worker, instances)| {
724 instances
725 .iter()
726 .filter(move |(binding, _)| {
727 resources
728 .get(worker)
729 .is_none_or(|r| !r.r2_buckets.iter().any(|b| &b.binding == *binding))
730 })
731 .map(move |(binding, bucket)| OrphanR2 {
732 worker: worker.clone(),
733 binding: binding.clone(),
734 bucket: bucket.name.clone(),
735 jurisdiction: bucket.jurisdiction.clone(),
736 })
737 })
738 .chain(env.r2_instances.iter().flat_map(|(worker, instances)| {
739 instances.iter().flat_map(move |(binding, bucket)| {
742 bucket.superseded.iter().map(move |past| OrphanR2 {
743 worker: worker.clone(),
744 binding: binding.clone(),
745 bucket: past.name.clone(),
746 jurisdiction: past.jurisdiction.clone(),
747 })
748 })
749 }))
750 .collect();
751 Orphans {
752 r2: r2_orphans,
753 kv: env
756 .kv
757 .keys()
758 .filter(|worker| resources.get(worker.as_str()).is_none_or(|r| !r.needs_kv))
759 .map(|worker| OrphanKv {
760 worker: worker.clone(),
761 binding: None,
762 })
763 .chain(instance_orphans)
764 .collect(),
765 workers: env
766 .workers
767 .keys()
768 .filter(|worker| !resources.contains_key(worker.as_str()))
769 .cloned()
770 .collect(),
771 queues: env
772 .queues
773 .iter()
774 .filter(|queue| !live_queues.contains(queue.as_str()))
775 .cloned()
776 .collect(),
777 }
778}
779
780pub(crate) fn recorded_r2<'a>(
782 lock: &'a DeployLock,
783 worker: &str,
784 binding: &str,
785 environment: &str,
786) -> Option<&'a R2Bucket> {
787 lock.environments
788 .get(environment)
789 .and_then(|env| env.r2_instances.get(worker))
790 .and_then(|instances| instances.get(binding))
791}
792
793pub(crate) fn recorded_ratelimit<'a>(
796 lock: &'a DeployLock,
797 worker: &str,
798 binding: &str,
799 environment: &str,
800) -> Option<&'a str> {
801 lock.environments
802 .get(environment)
803 .and_then(|env| env.ratelimit_instances.get(worker))
804 .and_then(|instances| instances.get(binding))
805 .map(|r| r.namespace_id.as_str())
806}
807
808pub(crate) fn is_bare_kv(binding: &str) -> bool {
810 binding == bynk_emit::emitter::wrangler::KV_BINDING_NAME
811}
812
813pub(crate) fn recorded_kv_binding<'a>(
816 lock: &'a DeployLock,
817 worker: &str,
818 binding: &str,
819 environment: &str,
820) -> Option<&'a str> {
821 if is_bare_kv(binding) {
822 return recorded_kv(lock, worker, environment);
823 }
824 lock.environments
825 .get(environment)
826 .and_then(|env| env.kv_instances.get(worker))
827 .and_then(|instances| instances.get(binding))
828 .map(|kv| kv.id.as_str())
829}
830
831pub(crate) fn kv_bindings(declared: &Resources) -> impl Iterator<Item = &str> {
835 declared
836 .needs_kv
837 .then_some(bynk_emit::emitter::wrangler::KV_BINDING_NAME)
838 .into_iter()
839 .chain(declared.kv_instances.iter().map(String::as_str))
840}
841
842pub(crate) fn recorded_kv<'a>(
843 lock: &'a DeployLock,
844 worker: &str,
845 environment: &str,
846) -> Option<&'a str> {
847 lock.environments
848 .get(environment)
849 .and_then(|env| env.kv.get(worker))
850 .map(|kv| kv.id.as_str())
851}
852
853pub(crate) fn should_trust_recorded_kv(
865 recorded: Option<&str>,
866 live_kv_ids: Option<&BTreeSet<String>>,
867) -> bool {
868 match (recorded, live_kv_ids) {
869 (Some(id), Some(live)) => live.contains(id),
870 (Some(_), None) => true,
871 (None, _) => false,
872 }
873}
874
875pub(crate) fn read_lock(path: &Path) -> Result<DeployLock, String> {
876 if !path.exists() {
877 return Ok(DeployLock {
878 version: lock_version(),
879 ..Default::default()
880 });
881 }
882 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
883 if text.trim().is_empty() {
888 return Err(format!(
889 "deploy ledger `{}` is empty or truncated (corrupt); refusing to \
890 treat it as a fresh project — restore it from version control",
891 path.display()
892 ));
893 }
894 let lock: DeployLock = toml::from_str(&text).map_err(|e| {
899 format!(
900 "deploy ledger `{}` is corrupt ({e}) — restore it from version control",
901 path.display()
902 )
903 })?;
904 if lock.version != lock_version() {
905 return Err(format!("unsupported deploy lock version {}", lock.version));
906 }
907 Ok(lock)
908}
909
910static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
912
913pub(crate) fn write_lock(path: &Path, lock: &DeployLock) -> Result<(), String> {
914 let body = toml::to_string_pretty(lock).map_err(|e| e.to_string())?;
915 let dir = path
923 .parent()
924 .filter(|p| !p.as_os_str().is_empty())
925 .unwrap_or_else(|| Path::new("."));
926 let file_name = path
927 .file_name()
928 .and_then(|n| n.to_str())
929 .unwrap_or(LOCK_FILE);
930
931 let (tmp, mut file) = loop {
935 let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
936 let candidate = dir.join(format!(".{file_name}.{}.{n}.tmp", std::process::id()));
937 match std::fs::OpenOptions::new()
938 .write(true)
939 .create_new(true)
940 .open(&candidate)
941 {
942 Ok(f) => break (candidate, f),
943 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
944 Err(e) => return Err(e.to_string()),
945 }
946 };
947
948 let write_then_sync = file
951 .write_all(body.as_bytes())
952 .and_then(|()| file.sync_all());
953 if let Err(e) = write_then_sync {
954 let _ = std::fs::remove_file(&tmp);
955 return Err(e.to_string());
956 }
957 drop(file);
958
959 if let Ok(meta) = std::fs::metadata(path) {
961 let _ = std::fs::set_permissions(&tmp, meta.permissions());
962 }
963
964 if let Err(e) = std::fs::rename(&tmp, path) {
965 let _ = std::fs::remove_file(&tmp);
966 return Err(e.to_string());
967 }
968 if let Ok(dir_file) = std::fs::File::open(dir) {
971 let _ = dir_file.sync_all();
972 }
973 Ok(())
974}
975
976pub fn materialise_deploy_state(
987 project_root: &Path,
988 worker: &str,
989 config: &Path,
990 environment: &str,
991) -> Result<bool, String> {
992 let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
994 let needed = bynk_emit::emitter::wrangler::kv_bindings_needing_ids(&text)?;
997 let buckets = bynk_emit::emitter::wrangler::r2_bucket_entries(&text)?;
1000 let limiters = bynk_emit::emitter::wrangler::ratelimits_needing_ids(&text)?;
1002 if needed.is_empty() && buckets.is_empty() && limiters.is_empty() {
1003 return Ok(false);
1004 }
1005 let lock = read_lock(&project_root.join(LOCK_FILE))?;
1006 let mut limiter_ids = BTreeMap::new();
1007 for binding in limiters {
1008 let Some(id) = recorded_ratelimit(&lock, worker, &binding, environment) else {
1009 return Err(format!(
1010 "remote rate limiter `{binding}` for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1011 ));
1012 };
1013 limiter_ids.insert(binding, id.to_string());
1014 }
1015 let mut names = BTreeMap::new();
1016 for (binding, name, jurisdiction) in &buckets {
1017 let Some(recorded) = recorded_r2(&lock, worker, binding, environment) else {
1018 return Err(format!(
1019 "remote R2 bucket `{binding}` for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1020 ));
1021 };
1022 if recorded.jurisdiction != *jurisdiction {
1023 return Err(format!(
1024 "remote R2 bucket `{binding}` for `{worker}` was created under another `@jurisdiction` than the source now names; run `bynk deploy --env {environment}` first"
1025 ));
1026 }
1027 names.insert(binding.clone(), r2_bucket_name(environment, name));
1028 }
1029 let mut changed = false;
1030 if !limiter_ids.is_empty() {
1031 if !materialise_ratelimit_ids(config, &limiter_ids) {
1032 return Err("could not write generated configuration".into());
1033 }
1034 changed = true;
1035 }
1036 let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
1037 if !names.is_empty() {
1038 let patched = bynk_emit::emitter::wrangler::materialise_r2_bucket_names(&text, &names)?;
1039 if patched != text {
1040 std::fs::write(config, patched).map_err(|e| e.to_string())?;
1041 changed = true;
1042 }
1043 }
1044 if needed.is_empty() {
1045 return Ok(changed);
1046 }
1047 let mut ids = BTreeMap::new();
1048 for binding in needed {
1049 let Some(id) = recorded_kv_binding(&lock, worker, &binding, environment) else {
1050 let what = if is_bare_kv(&binding) {
1051 format!("remote KV for `{worker}`")
1052 } else {
1053 format!("remote KV namespace `{binding}` for `{worker}`")
1054 };
1055 return Err(format!(
1056 "{what} has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
1057 ));
1058 };
1059 ids.insert(binding, id.to_string());
1060 }
1061 if materialise_kv_ids(config, &ids) {
1062 Ok(true)
1063 } else {
1064 Err("could not write generated configuration".into())
1065 }
1066}
1067
1068pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans, environment: &str) -> bool {
1080 for kv in &orphans.kv {
1081 match &kv.binding {
1082 None => eprintln!("bynk: will delete KV namespace for `{}`", kv.worker),
1083 Some(binding) => eprintln!(
1084 "bynk: will delete KV namespace `{}` for `{}`'s `{binding}`",
1085 kv_title(&kv.worker, binding, environment),
1086 kv.worker
1087 ),
1088 }
1089 }
1090 for queue in &orphans.queues {
1091 eprintln!(
1093 "bynk: will delete queue `{}`",
1094 env_qualify(environment, queue)
1095 );
1096 }
1097 if yes {
1098 return true;
1099 }
1100 if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
1101 eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
1102 return false;
1103 }
1104 let count = orphans.kv.len() + orphans.queues.len();
1105 eprint!("Delete {count} resource(s)? [y/N] ");
1106 let _ = io::stderr().flush();
1107 let mut answer = String::new();
1108 io::stdin().read_line(&mut answer).is_ok()
1109 && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
1110}
1111
1112pub(crate) fn prune_orphans(
1124 provenance: &Provenance,
1125 project_root: &Path,
1126 lock: &mut DeployLock,
1127 lock_path: &Path,
1128 environment: &str,
1129 orphans: &Orphans,
1130) -> Result<(), DeployFailure> {
1131 for orphan in &orphans.kv {
1132 let (worker, binding) = (orphan.worker.as_str(), orphan.binding());
1133 let Some(id) = recorded_kv_binding(lock, worker, binding, environment).map(str::to_owned)
1134 else {
1135 continue;
1136 };
1137 let what = match &orphan.binding {
1138 None => format!("the orphaned KV namespace for `{worker}`"),
1139 Some(binding) => format!("the orphaned KV namespace `{binding}` for `{worker}`"),
1140 };
1141 delete_kv_namespace(provenance, &id, project_root)
1142 .map_err(|e| DeployFailure::driver(format!("could not delete {what}: {e}")))?;
1143 lock.forget_kv(environment, worker, binding);
1144 write_lock(lock_path, lock).map_err(|e| {
1145 DeployFailure::driver(format!(
1146 "deleted {what} but could not record it in {}: {e}",
1147 lock_path.display()
1148 ))
1149 })?;
1150 }
1151 for queue in &orphans.queues {
1152 let physical = env_qualify(environment, queue);
1153 delete_queue(provenance, &physical, project_root).map_err(|e| {
1154 DeployFailure::driver(format!(
1155 "could not delete the orphaned queue `{physical}`: {e}"
1156 ))
1157 })?;
1158 if let Some(env) = lock.environments.get_mut(environment) {
1159 env.queues.remove(queue);
1160 }
1161 write_lock(lock_path, lock).map_err(|e| {
1162 DeployFailure::driver(format!(
1163 "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
1164 lock_path.display()
1165 ))
1166 })?;
1167 }
1168 Ok(())
1169}
1170
1171#[cfg(test)]
1172pub(crate) mod tests {
1173 use super::*;
1174 use crate::deploy::config::tests::project;
1175
1176 fn names(v: &[&str]) -> Vec<String> {
1177 v.iter().map(|s| s.to_string()).collect()
1178 }
1179
1180 pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
1181 let mut lock = DeployLock::default();
1182 for worker in workers {
1183 lock.record_deployed("default", worker, Some(Default::default()));
1184 }
1185 lock
1186 }
1187
1188 pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
1190 lock.environments
1191 .entry("default".into())
1192 .or_default()
1193 .kv
1194 .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
1195 lock
1196 }
1197
1198 pub(crate) fn with_kv_instance(
1200 mut lock: DeployLock,
1201 worker: &str,
1202 binding: &str,
1203 ) -> DeployLock {
1204 lock.record_kv("default", worker, binding, &format!("{binding}-id"));
1205 lock
1206 }
1207
1208 fn bare_orphan(worker: &str) -> OrphanKv {
1209 OrphanKv {
1210 worker: worker.to_string(),
1211 binding: None,
1212 }
1213 }
1214
1215 fn instance_orphan(worker: &str, binding: &str) -> OrphanKv {
1216 OrphanKv {
1217 worker: worker.to_string(),
1218 binding: Some(binding.to_string()),
1219 }
1220 }
1221
1222 pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
1224 lock.record_queue("default", queue);
1225 lock
1226 }
1227
1228 fn scratch_lock_path(label: &str) -> std::path::PathBuf {
1229 let unique = std::time::SystemTime::now()
1230 .duration_since(std::time::UNIX_EPOCH)
1231 .unwrap()
1232 .as_nanos();
1233 std::env::temp_dir().join(format!(
1234 "bynk-{label}-{}-{unique}.deploy.lock",
1235 std::process::id()
1236 ))
1237 }
1238
1239 fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
1240 let unique = std::time::SystemTime::now()
1241 .duration_since(std::time::UNIX_EPOCH)
1242 .unwrap()
1243 .as_nanos();
1244 let dir =
1245 std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
1246 std::fs::create_dir_all(&dir).unwrap();
1247 dir
1248 }
1249
1250 fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
1251 std::fs::read_dir(dir)
1252 .unwrap()
1253 .filter_map(|e| e.ok().map(|e| e.path()))
1254 .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
1255 .collect()
1256 }
1257
1258 #[test]
1262 fn rate_limiter_ids_are_allocated_across_environments() {
1263 let mut lock = DeployLock::default();
1264 let api = ratelimit_seed("default", "api", "RATELIMIT_A");
1265 assert_eq!(
1266 lock.allocate_ratelimit("default", "api", "RATELIMIT_A"),
1267 (api.to_string(), true)
1268 );
1269 assert_eq!(
1270 lock.allocate_ratelimit("default", "api", "RATELIMIT_A"),
1271 (api.to_string(), false)
1272 );
1273 let staging = lock.allocate_ratelimit("staging", "api", "RATELIMIT_A").0;
1274 assert_ne!(staging, api.to_string(), "each environment its own");
1275 let mut crowded = DeployLock::default();
1277 let seed = ratelimit_seed("default", "web", "RATELIMIT_B");
1278 crowded
1279 .environments
1280 .entry("staging".into())
1281 .or_default()
1282 .ratelimit_instances
1283 .insert(
1284 "other".into(),
1285 BTreeMap::from([(
1286 "RATELIMIT_X".into(),
1287 RateLimitNamespace {
1288 namespace_id: seed.to_string(),
1289 },
1290 )]),
1291 );
1292 assert_eq!(
1293 crowded.allocate_ratelimit("default", "web", "RATELIMIT_B"),
1294 ((seed + 1).to_string(), true)
1295 );
1296 assert!(lock.forget_undeclared_ratelimits("default", "api", &[]));
1297 assert!(!lock.forget_undeclared_ratelimits("default", "api", &[]));
1298 assert!(
1299 !lock.environments["default"]
1300 .ratelimit_instances
1301 .contains_key("api")
1302 );
1303 }
1304
1305 #[test]
1310 fn two_projects_ledgers_do_not_start_their_limiters_at_one_id() {
1311 let mut shop = DeployLock::default();
1312 let mut registry = DeployLock::default();
1313 let a = shop
1314 .allocate_ratelimit("default", "shop-cart", "RATELIMIT_LOGIN")
1315 .0;
1316 let b = registry
1317 .allocate_ratelimit("default", "registry-publish", "RATELIMIT_LOGIN")
1318 .0;
1319 assert_ne!(a, b);
1320 for id in [&a, &b] {
1321 let n: u64 = id.parse().unwrap();
1322 assert!(
1323 (FIRST_RATELIMIT_NAMESPACE..FIRST_RATELIMIT_NAMESPACE + RATELIMIT_NAMESPACE_SPAN)
1324 .contains(&n)
1325 );
1326 }
1327 }
1328
1329 #[test]
1330 fn lock_round_trip_is_environment_keyed() {
1331 let lock = DeployLock {
1332 version: 1,
1333 environments: BTreeMap::from([(
1334 "default".into(),
1335 Environment {
1336 deleted_classes: BTreeMap::new(),
1337 renamed_classes: BTreeMap::new(),
1338 kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
1339 kv_instances: BTreeMap::from([(
1340 "api".into(),
1341 BTreeMap::from([("KV_SESSIONS".into(), KvNamespace { id: "s".into() })]),
1342 )]),
1343 ratelimit_instances: BTreeMap::from([(
1344 "api".into(),
1345 BTreeMap::from([(
1346 "RATELIMIT_LOGIN".into(),
1347 RateLimitNamespace {
1348 namespace_id: "1000".into(),
1349 },
1350 )]),
1351 )]),
1352 r2_instances: BTreeMap::from([(
1353 "api".into(),
1354 BTreeMap::from([(
1355 "R2_ARCHIVES".into(),
1356 R2Bucket {
1357 name: "api-archives".into(),
1358 jurisdiction: Some("eu".into()),
1359 superseded: vec![R2Past {
1360 name: "api-archives".into(),
1361 jurisdiction: None,
1362 }],
1363 },
1364 )]),
1365 )]),
1366 workers: BTreeMap::from([(
1367 "api".into(),
1368 WorkerRecord {
1369 deployed: true,
1370 contracts: Default::default(),
1371 expects: Some(BTreeMap::from([(
1372 "shop.payment".into(),
1373 BTreeMap::from([("authorise".into(), "febd15c2757b72e1".into())]),
1374 )])),
1375 assets: BTreeSet::new(),
1376 foreign: BTreeMap::from([(
1377 "Legacy".into(),
1378 BTreeMap::from([("ping".into(), "4082ce9e2b74e919".into())]),
1379 )]),
1380 },
1381 )]),
1382 queues: BTreeSet::from(["intake".to_string()]),
1383 },
1384 )]),
1385 };
1386 assert_eq!(
1387 toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
1388 lock
1389 );
1390 }
1391
1392 #[test]
1393 fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
1394 let lock: DeployLock = toml::from_str(
1399 r#"
1400 version = 1
1401 [environments.default.kv.api]
1402 id = "abc"
1403 "#,
1404 )
1405 .expect("a slice-0 ledger must still parse");
1406 assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
1407 assert!(!lock.is_deployed("default", "api"));
1408 assert!(!lock.has_queue("default", "intake"));
1409 }
1410
1411 #[test]
1412 fn the_queue_set_serialises_as_names_under_the_environment() {
1413 let mut lock = DeployLock {
1417 version: 1,
1418 ..Default::default()
1419 };
1420 lock.record_queue("default", "job-intake");
1421 lock.record_queue("default", "job-retry");
1422 let text = toml::to_string_pretty(&lock).unwrap();
1423 assert!(
1424 text.contains("[environments.default]") && text.contains("queues = ["),
1425 "the queue set is environment-wide, not a per-worker table: {text}"
1426 );
1427 for queue in ["job-intake", "job-retry"] {
1428 assert!(
1429 text.contains(&format!("\"{queue}\"")),
1430 "{queue} is recorded"
1431 );
1432 }
1433 assert!(
1434 !text.contains("id"),
1435 "a queue is addressed by name — the ledger has no id to record: {text}"
1436 );
1437 assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
1438 }
1439
1440 #[test]
1443 fn a_workers_served_assets_round_trip_and_none_is_not_written() {
1444 let mut lock = DeployLock {
1445 version: 1,
1446 ..Default::default()
1447 };
1448 lock.record_deployed("default", "board", Some(Default::default()));
1449 lock.record_assets(
1450 "default",
1451 "board",
1452 ["/_assets/board.1a2b.css".to_string()]
1453 .into_iter()
1454 .collect(),
1455 );
1456 lock.record_deployed("default", "api", Some(Default::default()));
1457 lock.record_assets("default", "api", BTreeSet::new());
1458 let text = toml::to_string_pretty(&lock).unwrap();
1459 assert!(text.contains("\"/_assets/board.1a2b.css\""), "{text}");
1460 assert_eq!(
1461 text.matches("assets = ").count(),
1462 1,
1463 "only `board` has assets: {text}"
1464 );
1465 assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
1466 }
1467
1468 #[test]
1471 fn a_workers_foreign_callers_round_trip_and_none_is_not_written() {
1472 let mut lock = DeployLock {
1473 version: 1,
1474 ..Default::default()
1475 };
1476 let legacy = BTreeMap::from([(
1477 "Legacy".to_string(),
1478 BTreeMap::from([("ping".to_string(), "4082ce9e2b74e919".to_string())]),
1479 )]);
1480 lock.record_deployed("default", "shop-orders", Some(Default::default()));
1481 lock.record_foreign("default", "shop-orders", legacy.clone());
1482 lock.record_deployed("default", "shop-front", Some(Default::default()));
1483 lock.record_foreign("default", "shop-front", BTreeMap::new());
1484 let text = toml::to_string_pretty(&lock).unwrap();
1485 assert!(text.contains("4082ce9e2b74e919"), "{text}");
1486 assert_eq!(
1487 text.matches("foreign").count(),
1488 1,
1489 "only `shop-orders`: {text}"
1490 );
1491 let read = toml::from_str::<DeployLock>(&text).unwrap();
1492 assert_eq!(read, lock);
1493 assert_eq!(read.live_foreign("default", "shop-orders"), Some(&legacy));
1494 lock.record_deployed("default", "shop-orders", Some(Default::default()));
1496 lock.record_foreign("default", "shop-orders", BTreeMap::new());
1497 assert_eq!(
1498 lock.live_foreign("default", "shop-orders"),
1499 Some(&BTreeMap::new())
1500 );
1501 lock.record_foreign("default", "never", legacy);
1503 assert_eq!(lock.live_foreign("default", "never"), None);
1504 }
1505
1506 #[test]
1510 fn a_workers_expects_round_trip_and_empty_is_not_no_record() {
1511 let mut lock = DeployLock {
1512 version: 1,
1513 ..Default::default()
1514 };
1515 let orders = BTreeMap::from([(
1516 "shop.payment".to_string(),
1517 BTreeMap::from([("authorise".to_string(), "febd15c2757b72e1".to_string())]),
1518 )]);
1519 lock.record_deployed("default", "shop-orders", Some(Default::default()));
1520 lock.record_expects("default", "shop-orders", orders.clone());
1521 lock.record_deployed("default", "shop-payment", Some(Default::default()));
1522 lock.record_expects("default", "shop-payment", BTreeMap::new());
1523 let text = toml::to_string_pretty(&lock).unwrap();
1524 let read = toml::from_str::<DeployLock>(&text).unwrap();
1525 assert_eq!(read, lock, "{text}");
1526 let live: BTreeMap<_, _> = read.live_expects("default").collect();
1527 assert_eq!(live["shop-orders"], Some(&orders));
1528 assert_eq!(live["shop-payment"], Some(&BTreeMap::new()), "{text}");
1529 lock.record_expects("default", "never", orders);
1531 assert!(lock.live_expects("default").all(|(w, _)| w != "never"));
1532 }
1533
1534 #[test]
1537 fn a_lock_without_expects_still_reads_as_no_record() {
1538 let lock: DeployLock = toml::from_str(
1539 r#"
1540 version = 1
1541 [environments.default.workers.shop-orders]
1542 deployed = true
1543 [environments.default.workers.shop-payment]
1544 deployed = true
1545 [environments.default.workers.shop-payment.contracts]
1546 authorise = "febd15c2757b72e1"
1547 "#,
1548 )
1549 .expect("a lock without `expects` must still parse");
1550 assert!(lock.is_deployed("default", "shop-orders"));
1551 let live: BTreeMap<_, _> = lock.live_expects("default").collect();
1552 assert_eq!(live["shop-orders"], None);
1553 assert_eq!(live["shop-payment"], None);
1554 assert!(!toml::to_string_pretty(&lock).unwrap().contains("expects"));
1556 }
1557
1558 #[test]
1559 fn an_empty_queue_set_is_not_written_at_all() {
1560 let mut lock = DeployLock {
1563 version: 1,
1564 ..Default::default()
1565 };
1566 lock.record_deployed("default", "api", Some(Default::default()));
1567 assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
1568 }
1569
1570 #[test]
1571 fn materialises_only_the_placeholder() {
1572 let unique = std::time::SystemTime::now()
1578 .duration_since(std::time::UNIX_EPOCH)
1579 .unwrap()
1580 .as_nanos();
1581 let path =
1582 std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
1583 std::fs::write(
1584 &path,
1585 format!(
1586 "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
1587 ),
1588 )
1589 .unwrap();
1590 assert!(materialise_kv_ids(
1591 &path,
1592 &BTreeMap::from([("KV".to_string(), "abc".to_string())])
1593 ));
1594 let text = std::fs::read_to_string(&path).unwrap();
1595 let parsed: toml::Table = text.parse().unwrap();
1596 assert_eq!(
1597 parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
1598 Some("abc")
1599 );
1600 let _ = std::fs::remove_file(path);
1601 }
1602
1603 #[test]
1604 fn two_environments_do_not_cross_contaminate_the_ledger() {
1605 let mut lock = DeployLock {
1606 version: 1,
1607 ..Default::default()
1608 };
1609 lock.record_deployed("staging", "api", None);
1610 lock.record_queue("staging", "jobs");
1611 lock.environments
1612 .entry("staging".into())
1613 .or_default()
1614 .kv
1615 .insert(
1616 "api".into(),
1617 KvNamespace {
1618 id: "kv-staging".into(),
1619 },
1620 );
1621
1622 assert!(!lock.is_deployed("default", "api"));
1625 assert!(!lock.has_queue("default", "jobs"));
1626 assert_eq!(recorded_kv(&lock, "api", "default"), None);
1627
1628 assert!(lock.is_deployed("staging", "api"));
1629 assert!(lock.has_queue("staging", "jobs"));
1630 assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
1631 }
1632
1633 #[test]
1641 fn materialise_deploy_state_reads_the_named_environment_not_default() {
1642 let unique = std::time::SystemTime::now()
1643 .duration_since(std::time::UNIX_EPOCH)
1644 .unwrap()
1645 .as_nanos();
1646 let dir = std::env::temp_dir().join(format!(
1647 "bynk-materialise-state-{}-{unique}",
1648 std::process::id()
1649 ));
1650 std::fs::create_dir_all(&dir).unwrap();
1651 let project_root = dir.clone();
1652 let config = dir.join("wrangler.toml");
1653 std::fs::write(
1654 &config,
1655 format!(
1656 "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
1657 ),
1658 )
1659 .unwrap();
1660
1661 let mut lock = DeployLock {
1664 version: 1,
1665 ..Default::default()
1666 };
1667 lock.environments
1668 .entry("staging".into())
1669 .or_default()
1670 .kv
1671 .insert(
1672 "api".into(),
1673 KvNamespace {
1674 id: "kv-staging".into(),
1675 },
1676 );
1677 std::fs::write(
1678 project_root.join(LOCK_FILE),
1679 toml::to_string_pretty(&lock).unwrap(),
1680 )
1681 .unwrap();
1682
1683 let err = materialise_deploy_state(&project_root, "api", &config, "default")
1686 .expect_err("nothing is recorded under \"default\" — this must not silently pass");
1687 assert!(
1688 err.contains("environment `default`"),
1689 "the error should name which environment it looked under: {err}"
1690 );
1691
1692 assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
1695 let text = std::fs::read_to_string(&config).unwrap();
1696 let parsed: toml::Table = text.parse().unwrap();
1697 assert_eq!(
1698 parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
1699 Some("kv-staging")
1700 );
1701
1702 let _ = std::fs::remove_dir_all(dir);
1703 }
1704
1705 #[test]
1709 fn materialise_deploy_state_binds_each_rate_limiter() {
1710 let dir = scratch_lock_dir("materialise-ratelimits");
1712 let config = dir.join("wrangler.toml");
1713 std::fs::write(
1714 &config,
1715 "[[ratelimits]]\nname = \"RATELIMIT_LOGIN\"\nnamespace_id = \"<RATELIMIT_LOGIN_NAMESPACE>\" # set at deploy time\nsimple = { limit = 20, period = 60 }\n",
1716 )
1717 .unwrap();
1718 let mut lock = DeployLock {
1719 version: 1,
1720 ..Default::default()
1721 };
1722 std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1723 let err = materialise_deploy_state(&dir, "shop", &config, "default")
1724 .expect_err("an unallocated limiter must not pass");
1725 assert!(
1726 err.contains("`RATELIMIT_LOGIN`") && err.contains("bynk deploy"),
1727 "{err}"
1728 );
1729 lock.allocate_ratelimit("default", "shop", "RATELIMIT_LOGIN");
1730 std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1731 assert!(materialise_deploy_state(&dir, "shop", &config, "default").unwrap());
1732 let text = std::fs::read_to_string(&config).unwrap();
1733 let id = ratelimit_seed("default", "shop", "RATELIMIT_LOGIN");
1734 assert!(
1735 text.contains(&format!(
1736 "namespace_id = \"{id}\" # set at deploy time\nsimple = {{ limit = 20, period = 60 }}"
1737 )),
1738 "{text}"
1739 );
1740 let _ = std::fs::remove_dir_all(dir);
1741 }
1742
1743 #[test]
1744 fn materialise_deploy_state_binds_every_instance() {
1745 let dir = scratch_lock_dir("materialise-instances");
1746 let config = dir.join("wrangler.toml");
1747 std::fs::write(
1748 &config,
1749 "[[kv_namespaces]]\nbinding = \"KV_PRICE_CACHE\"\nid = \"<KV_PRICE_CACHE_ID>\"\n\n[[kv_namespaces]]\nbinding = \"KV_SESSIONS\"\nid = \"<KV_SESSIONS_ID>\"\n",
1750 )
1751 .unwrap();
1752 let mut lock = DeployLock {
1753 version: 1,
1754 ..Default::default()
1755 };
1756 lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sess");
1757 std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1758
1759 let err = materialise_deploy_state(&dir, "shop-cart", &config, "default")
1761 .expect_err("an unprovisioned instance must not pass");
1762 assert!(
1763 err.contains("`KV_PRICE_CACHE`") && err.contains("`shop-cart`"),
1764 "{err}"
1765 );
1766 assert!(
1767 std::fs::read_to_string(&config)
1768 .unwrap()
1769 .contains("<KV_SESSIONS_ID>")
1770 );
1771
1772 lock.record_kv("default", "shop-cart", "KV_PRICE_CACHE", "price");
1773 std::fs::write(dir.join(LOCK_FILE), toml::to_string_pretty(&lock).unwrap()).unwrap();
1774 assert!(materialise_deploy_state(&dir, "shop-cart", &config, "default").unwrap());
1775 let parsed: toml::Table = std::fs::read_to_string(&config).unwrap().parse().unwrap();
1776 let ids: Vec<&str> = parsed["kv_namespaces"]
1777 .as_array()
1778 .unwrap()
1779 .iter()
1780 .map(|ns| ns["id"].as_str().unwrap())
1781 .collect();
1782 assert_eq!(ids, ["price", "sess"]);
1783 let _ = std::fs::remove_dir_all(dir);
1784 }
1785
1786 #[test]
1789 fn a_bare_kv_ledger_is_written_as_it_was_before_instances() {
1790 let pre_s4b = "version = 1\n\n[environments.default.kv.api]\nid = \"abc\"\n";
1791 let lock: DeployLock = toml::from_str(pre_s4b).expect("a pre-S4b ledger reads");
1792 assert_eq!(
1793 recorded_kv_binding(&lock, "api", "KV", "default"),
1794 Some("abc")
1795 );
1796 assert_eq!(toml::to_string_pretty(&lock).unwrap(), pre_s4b);
1797 }
1798
1799 #[test]
1802 fn instances_are_recorded_by_worker_and_binding_and_forgotten_cleanly() {
1803 let mut lock = with_kv(
1804 DeployLock {
1805 version: 1,
1806 ..Default::default()
1807 },
1808 "shop-cart",
1809 );
1810 let before = toml::to_string_pretty(&lock).unwrap();
1811 lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sess");
1812 let text = toml::to_string_pretty(&lock).unwrap();
1813 assert!(
1814 text.contains("[environments.default.kv_instances.shop-cart.KV_SESSIONS]"),
1815 "{text}"
1816 );
1817 assert_eq!(
1818 recorded_kv_binding(&lock, "shop-cart", "KV_SESSIONS", "default"),
1819 Some("sess")
1820 );
1821 assert_eq!(
1822 recorded_kv_binding(&lock, "shop-cart", "KV", "default"),
1823 Some("ns-id")
1824 );
1825 assert_eq!(
1826 recorded_kv_binding(&lock, "shop-cart", "KV_OTHER", "default"),
1827 None
1828 );
1829 assert_eq!(
1830 toml::from_str::<DeployLock>(&text).unwrap(),
1831 lock,
1832 "the instance table round-trips"
1833 );
1834 lock.forget_kv("default", "shop-cart", "KV_SESSIONS");
1835 assert_eq!(toml::to_string_pretty(&lock).unwrap(), before);
1836 }
1837
1838 #[test]
1842 fn a_bare_namespace_its_live_worker_no_longer_declares_is_an_orphan() {
1843 let lock = with_kv(DeployLock::default(), "api");
1844 let switched = project(vec![(
1845 "api",
1846 Resources::default().kv_instances(&["KV_SESSIONS"]),
1847 )]);
1848 assert_eq!(
1849 find_orphans(&lock, "default", &switched).kv,
1850 [bare_orphan("api")]
1851 );
1852 let still_bare = project(vec![("api", Resources::default().needs_kv())]);
1853 assert!(find_orphans(&lock, "default", &still_bare).kv.is_empty());
1854 }
1855
1856 #[test]
1860 fn a_removed_or_renamed_instance_is_an_orphan() {
1861 let lock = with_kv_instance(
1862 with_kv_instance(
1863 with_kv_instance(with_kv(DeployLock::default(), "gone"), "gone", "KV_OLD"),
1864 "shop-cart",
1865 "KV_SESSIONS",
1866 ),
1867 "shop-cart",
1868 "KV_CARTS",
1869 );
1870 let resources = project(vec![(
1873 "shop-cart",
1874 Resources::default().kv_instances(&["KV_BASKETS", "KV_SESSIONS"]),
1875 )]);
1876 let orphans = find_orphans(&lock, "default", &resources);
1877 assert_eq!(
1878 orphans.kv,
1879 [
1880 bare_orphan("gone"),
1881 instance_orphan("gone", "KV_OLD"),
1882 instance_orphan("shop-cart", "KV_CARTS"),
1883 ]
1884 );
1885 assert!(orphans.has_prunable());
1886 }
1887
1888 #[test]
1894 fn materialise_deploy_state_is_a_no_op_without_a_kv_binding() {
1895 let unique = std::time::SystemTime::now()
1896 .duration_since(std::time::UNIX_EPOCH)
1897 .unwrap()
1898 .as_nanos();
1899 let dir = std::env::temp_dir().join(format!(
1900 "bynk-materialise-state-no-kv-{}-{unique}",
1901 std::process::id()
1902 ));
1903 std::fs::create_dir_all(&dir).unwrap();
1904 let config = dir.join("wrangler.toml");
1905 std::fs::write(&config, "name = \"api\"\nmain = \"index.ts\"\n").unwrap();
1906
1907 assert_eq!(
1908 materialise_deploy_state(&dir, "api", &config, "default"),
1909 Ok(false)
1910 );
1911
1912 let _ = std::fs::remove_dir_all(dir);
1913 }
1914
1915 #[test]
1918 fn a_removed_context_with_kv_is_two_orphans_not_one() {
1919 let mut lock = DeployLock {
1923 version: 1,
1924 ..Default::default()
1925 };
1926 lock.environments
1927 .entry("default".into())
1928 .or_default()
1929 .kv
1930 .insert(
1931 "gone".into(),
1932 KvNamespace {
1933 id: "kv-gone".into(),
1934 },
1935 );
1936 lock.record_deployed("default", "gone", None);
1937 let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
1939
1940 let orphans = find_orphans(&lock, "default", &resources);
1941 assert_eq!(orphans.kv, [bare_orphan("gone")]);
1942 assert_eq!(orphans.workers, names(&["gone"]));
1943 assert!(orphans.queues.is_empty());
1944 }
1945
1946 fn r2_lock() -> DeployLock {
1949 let mut lock = DeployLock {
1950 version: 1,
1951 ..Default::default()
1952 };
1953 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
1954 lock.record_r2("default", "shop", "R2_OLD", "shop-old", Some("eu"));
1955 lock
1956 }
1957
1958 #[test]
1959 fn a_removed_or_renamed_bucket_is_a_reported_orphan_that_prune_leaves_alone() {
1960 let resources = BTreeMap::from([(
1961 "shop".to_string(),
1962 Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", None)]),
1963 )]);
1964 let orphans = find_orphans(&r2_lock(), "default", &resources);
1965 assert_eq!(
1966 orphans.r2,
1967 [OrphanR2 {
1968 worker: "shop".into(),
1969 binding: "R2_OLD".into(),
1970 bucket: "shop-old".into(),
1971 jurisdiction: Some("eu".into())
1972 }]
1973 );
1974 assert!(!orphans.has_prunable());
1976 }
1977
1978 #[test]
1979 fn a_removed_worker_orphans_every_bucket_it_held() {
1980 let orphans = find_orphans(&r2_lock(), "default", &BTreeMap::new());
1981 assert_eq!(orphans.r2.len(), 2);
1982 assert!(
1983 find_orphans(&r2_lock(), "staging", &BTreeMap::new())
1984 .r2
1985 .is_empty()
1986 );
1987 }
1988
1989 #[test]
1990 fn a_bucket_is_recorded_under_the_environment_and_survives_the_file() {
1991 let lock = r2_lock();
1992 assert_eq!(
1993 recorded_r2(&lock, "shop", "R2_OLD", "default"),
1994 Some(&R2Bucket {
1995 name: "shop-old".into(),
1996 jurisdiction: Some("eu".into()),
1997 superseded: Vec::new()
1998 })
1999 );
2000 assert!(recorded_r2(&lock, "shop", "R2_OLD", "staging").is_none());
2001 let text = toml::to_string_pretty(&lock).unwrap();
2002 assert!(text.contains("[environments.default.r2_instances.shop.R2_OLD]"));
2003 let back: DeployLock = toml::from_str(&text).unwrap();
2004 assert_eq!(back, lock);
2005 assert!(
2007 !toml::to_string_pretty(&with_kv(DeployLock::default(), "api"))
2008 .unwrap()
2009 .contains("r2_instances")
2010 );
2011 }
2012
2013 fn r2_project(
2016 tag: &str,
2017 config: &str,
2018 lock: &DeployLock,
2019 ) -> (std::path::PathBuf, std::path::PathBuf) {
2020 let root = std::env::temp_dir().join(format!("bynk-r2-{tag}-{}", std::process::id()));
2021 let _ = std::fs::remove_dir_all(&root);
2022 std::fs::create_dir_all(root.join("workers/shop")).unwrap();
2023 let path = root.join("workers/shop/wrangler.toml");
2024 std::fs::write(&path, config).unwrap();
2025 write_lock(&root.join(LOCK_FILE), lock).unwrap();
2026 (root, path)
2027 }
2028
2029 const R2_CONFIG: &str = "name = \"shop\"\n\n[[r2_buckets]]\nbinding = \"R2_ARCHIVES\"\nbucket_name = \"shop-archives\"\n";
2030
2031 #[test]
2032 fn dev_remote_binds_a_recorded_bucket_under_the_environments_name() {
2033 let mut lock = DeployLock {
2034 version: 1,
2035 ..Default::default()
2036 };
2037 lock.record_r2(
2038 "staging",
2039 "shop",
2040 "R2_ARCHIVES",
2041 "shop-archives-staging",
2042 None,
2043 );
2044 let (root, config) = r2_project("staging", R2_CONFIG, &lock);
2045 assert_eq!(
2046 materialise_deploy_state(&root, "shop", &config, "staging"),
2047 Ok(true)
2048 );
2049 let text = std::fs::read_to_string(&config).unwrap();
2050 assert!(
2051 text.contains("bucket_name = \"shop-archives-staging\""),
2052 "{text}"
2053 );
2054 let _ = std::fs::remove_dir_all(root);
2055 }
2056
2057 #[test]
2058 fn dev_remote_leaves_the_default_environments_bucket_as_generated() {
2059 let mut lock = DeployLock {
2060 version: 1,
2061 ..Default::default()
2062 };
2063 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2064 let (root, config) = r2_project("default", R2_CONFIG, &lock);
2065 assert_eq!(
2066 materialise_deploy_state(&root, "shop", &config, "default"),
2067 Ok(false)
2068 );
2069 assert_eq!(std::fs::read_to_string(&config).unwrap(), R2_CONFIG);
2070 let _ = std::fs::remove_dir_all(root);
2071 }
2072
2073 #[test]
2074 fn a_replaced_bucket_stays_recorded_and_is_reported_while_its_binding_is_live() {
2075 let mut lock = DeployLock {
2076 version: 1,
2077 ..Default::default()
2078 };
2079 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2080 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2082 assert!(
2083 recorded_r2(&lock, "shop", "R2_ARCHIVES", "default")
2084 .unwrap()
2085 .superseded
2086 .is_empty()
2087 );
2088 lock.record_r2(
2089 "default",
2090 "shop",
2091 "R2_ARCHIVES",
2092 "shop-archives",
2093 Some("eu"),
2094 );
2095 let resources = BTreeMap::from([(
2096 "shop".to_string(),
2097 Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", Some("eu"))]),
2098 )]);
2099 assert_eq!(
2100 find_orphans(&lock, "default", &resources).r2,
2101 [OrphanR2 {
2102 worker: "shop".into(),
2103 binding: "R2_ARCHIVES".into(),
2104 bucket: "shop-archives".into(),
2105 jurisdiction: None
2106 }]
2107 );
2108 let text = toml::to_string_pretty(&lock).unwrap();
2109 assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
2110 }
2111
2112 #[test]
2113 fn dev_remote_refuses_a_bucket_recorded_under_another_jurisdiction() {
2114 let mut lock = DeployLock {
2115 version: 1,
2116 ..Default::default()
2117 };
2118 lock.record_r2(
2119 "default",
2120 "shop",
2121 "R2_ARCHIVES",
2122 "shop-archives",
2123 Some("eu"),
2124 );
2125 let (root, config) = r2_project("jurisdiction", R2_CONFIG, &lock);
2126 let error = materialise_deploy_state(&root, "shop", &config, "default").unwrap_err();
2127 assert!(error.contains("another `@jurisdiction`"), "{error}");
2128 let _ = std::fs::remove_dir_all(root);
2129 }
2130
2131 #[test]
2132 fn dev_remote_refuses_a_bucket_no_deploy_has_made() {
2133 let (root, config) = r2_project(
2134 "unprovisioned",
2135 R2_CONFIG,
2136 &DeployLock {
2137 version: 1,
2138 ..Default::default()
2139 },
2140 );
2141 let error = materialise_deploy_state(&root, "shop", &config, "default").unwrap_err();
2142 assert!(error.contains("R2 bucket `R2_ARCHIVES`"), "{error}");
2143 assert!(
2144 error.contains("run `bynk deploy --env default` first"),
2145 "{error}"
2146 );
2147 let _ = std::fs::remove_dir_all(root);
2148 }
2149
2150 #[test]
2151 fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
2152 let mut lock = DeployLock {
2156 version: 1,
2157 ..Default::default()
2158 };
2159 lock.record_queue("default", "jobs");
2160 let resources = BTreeMap::from([
2161 ("orders".into(), Resources::default().consumes(&["jobs"])),
2162 ("billing".into(), Resources::default().consumes(&["jobs"])),
2163 ]);
2164
2165 let orphans = find_orphans(&lock, "default", &resources);
2166 assert!(
2167 orphans.queues.is_empty(),
2168 "jobs is still consumed by two live contexts: {orphans:?}"
2169 );
2170 }
2171
2172 #[test]
2173 fn a_queue_no_context_declares_anymore_is_orphaned() {
2174 let mut lock = DeployLock {
2175 version: 1,
2176 ..Default::default()
2177 };
2178 lock.record_queue("default", "stale-jobs");
2179 let resources = BTreeMap::from([("orders".into(), Resources::default())]);
2180
2181 let orphans = find_orphans(&lock, "default", &resources);
2182 assert_eq!(orphans.queues, names(&["stale-jobs"]));
2183 }
2184
2185 #[test]
2186 fn find_orphans_is_scoped_to_the_named_environment() {
2187 let mut lock = DeployLock {
2190 version: 1,
2191 ..Default::default()
2192 };
2193 lock.environments
2194 .entry("staging".into())
2195 .or_default()
2196 .kv
2197 .insert(
2198 "gone".into(),
2199 KvNamespace {
2200 id: "kv-staging-gone".into(),
2201 },
2202 );
2203 let resources = BTreeMap::new();
2204
2205 assert!(
2206 find_orphans(&lock, "default", &resources).kv.is_empty(),
2207 "default"
2208 );
2209 assert_eq!(
2210 find_orphans(&lock, "staging", &resources).kv,
2211 [bare_orphan("gone")]
2212 );
2213 }
2214
2215 #[test]
2216 fn an_absent_environment_has_no_orphans() {
2217 let lock = DeployLock::default();
2218 let orphans = find_orphans(&lock, "default", &BTreeMap::new());
2219 assert_eq!(orphans, Orphans::default());
2220 }
2221
2222 #[test]
2223 fn has_prunable_ignores_worker_only_orphans() {
2224 let worker_only = Orphans {
2228 workers: names(&["gone"]),
2229 ..Default::default()
2230 };
2231 assert!(!worker_only.has_prunable());
2232
2233 let with_kv = Orphans {
2234 kv: vec![bare_orphan("gone")],
2235 ..Default::default()
2236 };
2237 assert!(with_kv.has_prunable());
2238
2239 let with_queue = Orphans {
2240 queues: names(&["gone"]),
2241 ..Default::default()
2242 };
2243 assert!(with_queue.has_prunable());
2244
2245 assert!(!Orphans::default().has_prunable());
2246 }
2247
2248 #[test]
2249 fn a_written_ledger_reads_back_identically() {
2250 let path = scratch_lock_path("roundtrip");
2253 let mut lock = DeployLock {
2254 version: 1,
2255 ..Default::default()
2256 };
2257 lock.environments
2258 .entry("default".into())
2259 .or_default()
2260 .kv
2261 .insert(
2262 "api".into(),
2263 KvNamespace {
2264 id: "kv-123".into(),
2265 },
2266 );
2267 write_lock(&path, &lock).unwrap();
2268 assert_eq!(read_lock(&path).unwrap(), lock);
2269 let _ = std::fs::remove_file(path);
2270 }
2271
2272 #[test]
2273 fn an_empty_ledger_is_corruption_not_a_fresh_project() {
2274 let path = scratch_lock_path("empty");
2278 std::fs::write(&path, "").unwrap();
2279 assert!(
2280 read_lock(&path).is_err(),
2281 "a zero-byte ledger must be rejected, not treated as no environments"
2282 );
2283 std::fs::write(&path, " \n\t\n").unwrap();
2284 assert!(
2285 read_lock(&path).is_err(),
2286 "a whitespace-only ledger is just as corrupt"
2287 );
2288 let _ = std::fs::remove_file(&path);
2289 assert!(
2290 read_lock(&path).is_ok(),
2291 "an absent ledger is a fresh project, not corruption"
2292 );
2293 }
2294
2295 #[test]
2296 fn a_ledger_without_a_version_is_rejected() {
2297 let path = scratch_lock_path("noversion");
2300 std::fs::write(&path, "[environments]\n").unwrap();
2301 assert!(read_lock(&path).is_err());
2302 let _ = std::fs::remove_file(path);
2303 }
2304
2305 #[test]
2306 fn a_successful_write_leaves_no_temp_litter() {
2307 let dir = scratch_lock_dir("nolitter");
2310 let path = dir.join(LOCK_FILE);
2311 let lock = DeployLock {
2312 version: 1,
2313 ..Default::default()
2314 };
2315 write_lock(&path, &lock).unwrap();
2316 write_lock(&path, &lock).unwrap(); assert!(
2318 temp_litter(&dir).is_empty(),
2319 "no `.tmp` sibling may outlive the rename"
2320 );
2321 assert_eq!(read_lock(&path).unwrap(), lock);
2322 let _ = std::fs::remove_dir_all(dir);
2323 }
2324
2325 #[cfg(unix)]
2326 #[test]
2327 fn a_rewrite_preserves_the_ledger_permissions() {
2328 use std::os::unix::fs::PermissionsExt;
2329 let dir = scratch_lock_dir("perms");
2332 let path = dir.join(LOCK_FILE);
2333 let lock = DeployLock {
2334 version: 1,
2335 ..Default::default()
2336 };
2337 write_lock(&path, &lock).unwrap();
2338 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2339 write_lock(&path, &lock).unwrap();
2340 assert_eq!(
2341 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2342 0o600,
2343 "the rewrite must carry the ledger's own mode across the rename"
2344 );
2345 let _ = std::fs::remove_dir_all(dir);
2346 }
2347}