1use super::*;
2
3const CONTRACT_SKEW: &str = "bynk.deploy.contract_skew";
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub enum DeployFormat {
9 #[default]
10 Short,
11 Json,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct DeployOptions {
16 pub dry_run: bool,
17 pub format: DeployFormat,
18 pub yes: bool,
19 pub context: Option<String>,
23 pub environment: String,
32 pub secrets_file: Option<std::path::PathBuf>,
35 pub secrets: Vec<String>,
39 pub force: bool,
41 pub prune: bool,
46 pub confirm_delete: Vec<String>,
50 pub wrangler_args: Vec<String>,
51}
52
53#[derive(Debug, Serialize)]
57pub(crate) struct Plan<'a> {
58 environment: &'a str,
59 #[serde(skip)]
63 orphans: Orphans,
64 #[serde(rename = "orphans")]
68 shown_orphans: Orphans,
69 order: Vec<String>,
73 contexts: Vec<ContextPlan<'a>>,
74}
75
76#[derive(Debug, Serialize)]
77struct ContextPlan<'a> {
78 worker: String,
81 kv: Vec<PlanKv<'a>>,
85 #[serde(skip_serializing_if = "Vec::is_empty")]
87 r2: Vec<PlanR2<'a>>,
88 #[serde(skip_serializing_if = "Vec::is_empty")]
91 ratelimits: Vec<PlanRateLimit<'a>>,
92 queues: Vec<PlanQueue>,
94 durable_objects: Vec<PlanDurableObject<'a>>,
98 #[serde(
101 rename = "durable_objects_renamed",
102 skip_serializing_if = "Vec::is_empty"
103 )]
104 renamed_durable_objects: Vec<PlanDurableObjectRename<'a>>,
105 #[serde(
108 rename = "durable_objects_deleted",
109 skip_serializing_if = "Vec::is_empty"
110 )]
111 deleted_durable_objects: Vec<PlanDeletion>,
112 secrets: Vec<PlanSecret>,
114 secrets_complete: bool,
123 action: &'static str,
126 binds_to: Vec<String>,
129 foreign_services: Vec<PlanForeignService<'a>>,
132}
133
134#[derive(Debug, Serialize)]
139struct PlanForeignService<'a> {
140 binding: &'a str,
141 worker: &'a str,
142}
143
144#[derive(Debug, Serialize)]
145struct PlanKv<'a> {
146 binding: &'a str,
148 action: &'static str,
149 namespace: String,
151}
152
153#[derive(Debug, Serialize)]
157struct PlanRateLimit<'a> {
158 binding: &'a str,
160 action: &'static str,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 namespace_id: Option<String>,
165}
166
167#[derive(Debug, Serialize)]
170struct PlanR2<'a> {
171 binding: &'a str,
173 action: &'static str,
176 bucket: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 jurisdiction: Option<&'a str>,
180}
181
182#[derive(Debug, Serialize)]
183struct PlanQueue {
184 action: &'static str,
189 queue: String,
192}
193
194#[derive(Debug, Serialize)]
203struct PlanSecret {
204 name: String,
207 origin: Origin,
212 action: &'static str,
215}
216
217#[derive(Debug, Serialize)]
221struct PlanDurableObject<'a> {
222 class: &'a str,
223 storage: &'a str,
226 reconciled_by: &'static str,
232}
233
234#[derive(Debug, Serialize)]
239struct PlanDurableObjectRename<'a> {
240 from: &'a str,
241 to: &'a str,
242 already_applied: bool,
243}
244
245#[derive(Debug, Serialize)]
250struct PlanDeletion {
251 class: String,
252 already_applied: bool,
253}
254
255fn qualified_class(worker: &str, class: &str) -> String {
260 format!("{}.{class}", worker.replace('-', "."))
261}
262
263fn record_pushed_tombstones(
268 lock: &mut DeployLock,
269 environment: &str,
270 worker: &str,
271 pushed: &Resources,
272) {
273 lock.record_deleted_classes(
274 environment,
275 worker,
276 pushed.deleted_durable_objects.iter().cloned().collect(),
277 );
278 lock.record_renamed_classes(
279 environment,
280 worker,
281 pushed.renamed_durable_objects.iter().cloned().collect(),
282 );
283}
284
285fn confirm_deletions(plan: &Plan<'_>, confirmed: &[String], lock: &DeployLock) -> bool {
291 let all: Vec<&PlanDeletion> = plan
292 .contexts
293 .iter()
294 .flat_map(|c| c.deleted_durable_objects.iter())
295 .collect();
296 let deletions: Vec<&String> = all
298 .iter()
299 .filter(|d| !d.already_applied)
300 .map(|d| &d.class)
301 .collect();
302 for name in confirmed {
303 let applied = all.iter().any(|d| &d.class == name)
307 || name.rsplit_once('.').is_some_and(|(context, class)| {
308 lock.has_deleted_class(plan.environment, &context.replace('.', "-"), class)
309 });
310 if !deletions.contains(&name) && !applied {
311 eprintln!("bynk: --confirm-delete {name} names no class this plan deletes; refusing");
312 return false;
313 }
314 }
315 let unconfirmed: Vec<&&String> = deletions
316 .iter()
317 .filter(|d| !confirmed.iter().any(|c| c == **d))
318 .collect();
319 if unconfirmed.is_empty() {
320 return true;
321 }
322 if !io::stdin().is_terminal() {
323 eprintln!(
324 "bynk: this deploy deletes stored agent data; confirm each class with {}",
325 unconfirmed
326 .iter()
327 .map(|d| format!("--confirm-delete {d}"))
328 .collect::<Vec<_>>()
329 .join(" ")
330 );
331 return false;
332 }
333 for name in unconfirmed {
334 eprint!(
335 "Deleting `{name}` destroys all of its stored data, irreversibly. Type `{name}` to \
336 confirm: "
337 );
338 let _ = io::stderr().flush();
339 let mut answer = String::new();
340 if io::stdin().read_line(&mut answer).is_err() || answer.trim() != name.as_str() {
341 eprintln!("bynk: `{name}` not confirmed; nothing was deployed");
342 return false;
343 }
344 }
345 true
346}
347
348pub(crate) fn conflicting_env_passthrough(wrangler_args: &[String]) -> Option<&str> {
362 wrangler_args
363 .iter()
364 .find(|arg| {
365 ["--env", "--environment"]
366 .iter()
367 .any(|flag| arg.as_str() == *flag || arg.starts_with(&format!("{flag}=")))
368 })
369 .map(String::as_str)
370}
371
372pub(crate) fn secrets_file_passthrough_refusal(
380 wrangler_args: &[String],
381 order: &[String],
382 lock: &DeployLock,
383 environment: &str,
384) -> Option<String> {
385 let arg = wrangler_args
386 .iter()
387 .find(|arg| arg.as_str() == "--secrets-file" || arg.starts_with("--secrets-file="))?;
388 let first: Vec<String> = order
389 .iter()
390 .filter(|worker| !lock.is_deployed(environment, worker))
391 .map(|worker| format!("`{}`", deployed_worker_name(environment, worker)))
392 .collect();
393 if first.is_empty() {
394 return None;
395 }
396 Some(format!(
397 "bynk: `{arg}` after `--` conflicts with the secrets file `bynk deploy` uploads with a new Worker's first version ({}).\n \
398 Pass it to bynk instead, before `--`: `bynk deploy --secrets-file <file>`.\n",
399 first.join(", ")
400 ))
401}
402
403pub fn run(
405 tb: &dyn Toolbox,
406 compiler: &Compiler,
407 project_root: &Path,
408 node_floor: u32,
409 opts: &DeployOptions,
410) -> ExitCode {
411 if let Some(conflict) = conflicting_env_passthrough(&opts.wrangler_args) {
419 eprintln!(
420 "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
421 opts.environment
422 );
423 return ExitCode::FAILURE;
424 }
425
426 let preflight_opts = DoctorOptions {
427 only: Some(Capability::Deploy),
428 strict: false,
429 };
430 let report = doctor::diagnose(
431 tb,
432 compiler,
433 &Context {
434 project_root: Some(project_root.to_path_buf()),
435 in_repo: false,
436 node_floor,
437 },
438 &preflight_opts,
439 );
440 if report.exit_nonzero(&preflight_opts) {
441 eprint!("{}", preflight_failure_message(&report));
442 return ExitCode::FAILURE;
443 }
444
445 let build_dir = project_root.join(".bynk").join("deploy");
446 if let Err(e) = workers::prepare_build_dir(project_root, &build_dir) {
447 eprintln!("bynk: could not prepare build directory: {e}");
448 return ExitCode::FAILURE;
449 }
450 if !workers::compile_once(compiler, project_root, &build_dir, true) {
451 return ExitCode::FAILURE;
452 }
453 let workers_dir = build_dir.join("workers");
455 let available = workers::discover_workers(&workers_dir);
456 let selected = match workers::select_contexts(&available, opts.context.as_slice()) {
457 Ok(selected) => selected,
458 Err(e) => {
459 eprintln!("bynk: {e}");
460 return ExitCode::FAILURE;
461 }
462 };
463 let resources = match project_resources(&workers_dir, &available) {
467 Ok(resources) => resources,
468 Err(e) => {
469 eprintln!("bynk: {e}");
470 return ExitCode::FAILURE;
471 }
472 };
473 let graph = service_graph(&resources);
474 let secret_source = match SecretSource::read(opts) {
478 Ok(source) => source,
479 Err(e) => {
480 eprintln!("bynk: {e}");
481 return ExitCode::FAILURE;
482 }
483 };
484 let lock_path = project_root.join(LOCK_FILE);
485 let mut lock = match read_lock(&lock_path) {
486 Ok(lock) => lock,
487 Err(e) => {
488 eprintln!("bynk: could not read {}: {e}", lock_path.display());
489 return ExitCode::FAILURE;
490 }
491 };
492
493 if opts.context.is_some()
497 && let [worker] = selected.as_slice()
498 {
499 let absent = absent_dependencies(worker, &graph, &lock, &opts.environment);
500 if !absent.is_empty() {
501 eprint!(
502 "{}",
503 absent_dependencies_refusal(&opts.environment, worker, &absent)
504 );
505 return ExitCode::FAILURE;
506 }
507
508 let manifest = match read_contracts_manifest(&workers_dir.join(worker)) {
513 Ok(m) => m,
514 Err(e) => {
515 eprintln!(
516 "bynk: could not read `{worker}`'s {}: {e}",
517 bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
518 );
519 return ExitCode::FAILURE;
520 }
521 };
522 let skews = contract_skews(
523 &manifest.expects,
524 &lock,
525 bynk_emit::project::worker_dir_name,
526 &opts.environment,
527 );
528 if !skews.is_empty() {
529 eprint!(
530 "{}",
531 contract_skew_refusal(&opts.environment, worker, &skews)
532 );
533 return ExitCode::FAILURE;
534 }
535
536 let callee_skews = callee_contract_skews(
540 worker,
541 &manifest.provides,
542 &lock,
543 bynk_emit::project::worker_dir_name,
544 &opts.environment,
545 );
546 if !callee_skews.is_empty() {
547 let (built, orphaned) = split_orphaned_callers(callee_skews, &graph);
550 if !built.is_empty() {
551 eprint!(
552 "{}",
553 callee_contract_skew_refusal(&opts.environment, worker, &built)
554 );
555 }
556 if !orphaned.is_empty() {
557 eprint!(
558 "{}",
559 orphaned_caller_skew_refusal(&opts.environment, worker, &orphaned)
560 );
561 }
562 return ExitCode::FAILURE;
563 }
564 for caller in unrecorded_callers(worker, &graph, &lock, &opts.environment) {
565 eprint!(
566 "{}",
567 unrecorded_caller_warning(&opts.environment, worker, &caller)
568 );
569 }
570 }
571
572 let order = match deploy_order(&selected, &graph) {
573 Ok(order) => order
574 .into_iter()
575 .filter(|worker| selected.contains(worker))
579 .collect::<Vec<_>>(),
580 Err(e) => {
581 eprintln!("bynk: {e}");
582 let OrderError::Cycle(path) = &e;
583 let publishers = cycle_publishers(path, &resources);
584 if !publishers.is_empty() {
585 eprintln!(
586 " The cycle runs through event delivery: {} {} events, so {} to each subscriber, and a subscriber on this cycle also calls back into {} services.",
587 publishers
588 .iter()
589 .map(|p| format!("`{p}`"))
590 .collect::<Vec<_>>()
591 .join(", "),
592 if publishers.len() == 1 {
593 "publishes"
594 } else {
595 "publish"
596 },
597 if publishers.len() == 1 {
598 "it binds"
599 } else {
600 "each binds"
601 },
602 if publishers.len() == 1 {
603 "its"
604 } else {
605 "their"
606 },
607 );
608 eprintln!(
609 " A subscriber that calls its publisher cannot be deployed yet; move the services it calls out of the publishing context."
610 );
611 }
612 return ExitCode::FAILURE;
613 }
614 };
615
616 let pins = match read_pins(project_root) {
620 Ok(pins) => pins,
621 Err(e) => {
622 eprintln!("bynk: {e}");
623 return ExitCode::FAILURE;
624 }
625 };
626 let mut built_foreign: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>> =
627 BTreeMap::new();
628 for worker in &order {
629 match read_contracts_manifest(&workers_dir.join(worker)) {
630 Ok(m) if !m.foreign.is_empty() => {
631 built_foreign.insert(context_of(worker), m.foreign);
632 }
633 Ok(_) => {}
634 Err(e) => {
635 eprintln!(
636 "bynk: could not read `{worker}`'s {}: {e}",
637 bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
638 );
639 return ExitCode::FAILURE;
640 }
641 }
642 }
643 let in_run: BTreeSet<String> = order.iter().map(|w| context_of(w)).collect();
644 let (broken, unpinned) = check_pins(&pins, &built_foreign, &in_run, opts.context.is_none());
645 if !broken.is_empty() {
646 eprint!("{}", refusal_message(&broken));
647 return ExitCode::FAILURE;
648 }
649 if !unpinned.is_empty() {
650 eprint!("{}", unpinned_message(&unpinned));
651 }
652
653 if let Some(message) = kv_title_collision(&resources, &lock, &opts.environment) {
658 eprintln!("bynk: {message}");
659 return ExitCode::FAILURE;
660 }
661 if let Some(message) = r2_bucket_problem(&resources, &lock, &opts.environment) {
664 eprintln!("bynk: {message}");
665 return ExitCode::FAILURE;
666 }
667 if let Some(refusal) =
672 secrets_file_passthrough_refusal(&opts.wrangler_args, &order, &lock, &opts.environment)
673 {
674 eprint!("{refusal}");
675 return ExitCode::FAILURE;
676 }
677
678 let plan = derive_plan(
679 &order,
680 &resources,
681 &lock,
682 &secret_source,
683 opts.force,
684 &opts.environment,
685 );
686 print_plan(&plan, opts.format);
687 if opts.dry_run {
688 return ExitCode::SUCCESS;
689 }
690 if let Some(refusal) = recreate_refusal(&plan, opts.yes) {
693 eprintln!("bynk: {refusal}");
694 return ExitCode::FAILURE;
695 }
696
697 if is_ci()
704 && let Some((worker, binding)) =
705 first_unrecorded_kv(&order, &resources, &lock, &opts.environment)
706 {
707 eprintln!(
708 "bynk: {} is unrecorded; provision locally first and commit {LOCK_FILE}",
709 kv_namespace_noun(worker, binding)
710 );
711 return ExitCode::FAILURE;
712 }
713 if is_ci()
717 && let Some((worker, binding)) =
718 first_unrecorded_ratelimit(&order, &resources, &lock, &opts.environment)
719 {
720 eprintln!(
721 "bynk: the rate limiter `{binding}` of `{worker}` is unrecorded; deploy locally first and commit {LOCK_FILE}"
722 );
723 return ExitCode::FAILURE;
724 }
725 let probe = probe::detect(
726 tb,
727 "wrangler",
728 DetectOpts {
729 project_root: Some(project_root),
730 allow_npx: true,
731 },
732 );
733 if let Some(refusal) = wrangler_floor_refusal(&probe, &order, &resources) {
735 eprintln!("{refusal}");
736 return ExitCode::FAILURE;
737 }
738 if !whoami(&probe.provenance) {
739 eprintln!(
740 "bynk: Cloudflare authentication is unavailable; run `wrangler login` or set CLOUDFLARE_API_TOKEN"
741 );
742 return ExitCode::FAILURE;
743 }
744 for warning in foreign_worker_warnings(&order, &resources, |name| {
749 worker_presence(&probe.provenance, name, project_root)
750 }) {
751 eprintln!("{warning}");
752 }
753 if !confirm_deletions(&plan, &opts.confirm_delete, &lock) {
756 return ExitCode::FAILURE;
757 }
758 if !confirm(opts.yes) {
759 return ExitCode::FAILURE;
760 }
761
762 let live_kv_ids = if order.iter().any(|w| {
769 kv_bindings(&resources[w])
770 .any(|b| recorded_kv_binding(&lock, w, b, &opts.environment).is_some())
771 }) {
772 live_kv_namespace_ids(&probe.provenance, project_root)
773 } else {
774 None
775 };
776
777 let mut attempted_queues = BTreeSet::new();
785 let in_run: BTreeSet<&str> = order.iter().map(String::as_str).collect();
795 let edges: Vec<Edge> = project_edges(&resources)
796 .into_iter()
797 .filter(|e| in_run.contains(e.caller.as_str()) || in_run.contains(e.callee.as_str()))
798 .collect();
799 let mut asked: BTreeSet<&str> = in_run.clone();
800 for e in &edges {
801 asked.insert(&e.caller);
802 asked.insert(&e.callee);
803 }
804 let listed: BTreeMap<String, Option<BTreeSet<String>>> = asked
805 .iter()
806 .map(|worker| {
807 let answer = list_secrets(
808 &probe.provenance,
809 &workers_dir.join(worker),
810 &opts.environment,
811 );
812 (worker.to_string(), answer)
813 })
814 .collect();
815 let mut edge_keys: BTreeMap<Edge, EdgeKey> = BTreeMap::new();
816 for edge in &edges {
817 let ends = match edge.ends() {
818 Ok(ends) => ends,
819 Err(e) => {
820 eprintln!("bynk: {e}");
821 return ExitCode::FAILURE;
822 }
823 };
824 let [callee, caller] = ends.map(|(worker, key)| {
825 let p = presence_of(
826 &key,
827 listed[worker].as_ref(),
828 !lock.is_deployed(&opts.environment, worker),
829 );
830 if p == Presence::Unknown {
833 eprint!(
834 "{}",
835 unknown_edge_key_notice(&opts.environment, worker, &key, edge)
836 );
837 }
838 p
839 });
840 let both_in_run =
841 in_run.contains(edge.caller.as_str()) && in_run.contains(edge.callee.as_str());
842 match decide_edge_key(
843 edge,
844 caller,
845 callee,
846 both_in_run,
847 opts.force,
848 &mut mint_edge_key,
849 &opts.environment,
850 ) {
851 Ok(decision) => {
852 edge_keys.insert(edge.clone(), decision);
853 }
854 Err(e) => {
855 eprintln!("bynk: {e}");
856 return ExitCode::FAILURE;
857 }
858 }
859 }
860 let foreign_secrets: BTreeSet<String> = resources
863 .values()
864 .flat_map(|r| r.foreign_callers.iter().map(|f| f.binding.clone()))
865 .collect();
866 let mut foreign_keys: BTreeMap<(String, String), ForeignKey> = BTreeMap::new();
867 for worker in &order {
868 for caller in &resources[worker].foreign_callers {
869 let presence = presence_of(
870 &caller.binding,
871 listed[worker].as_ref(),
872 !lock.is_deployed(&opts.environment, worker),
873 );
874 let supplied = value_from_source(&caller.binding, &secret_source);
875 match decide_foreign_key(supplied, presence, opts.force, &mut mint_edge_key) {
876 Ok(decision) => {
877 foreign_keys.insert((worker.clone(), caller.binding.clone()), decision);
878 }
879 Err(e) => {
880 eprint!(
881 "{}",
882 foreign_binding_refusal(&opts.environment, worker, caller, &e)
883 );
884 return ExitCode::FAILURE;
885 }
886 }
887 }
888 }
889 let mut resolved_secrets = BTreeMap::new();
892 let stale_removed = sweep_stale_upload_dirs(&std::env::temp_dir());
895 if stale_removed > 0 {
896 eprintln!(
897 "bynk: removed {stale_removed} secrets file(s) an interrupted deploy left in the temp directory"
898 );
899 }
900 for (i, worker) in order.iter().enumerate() {
901 if order.len() > 1 {
902 eprint!(
903 "{}",
904 deploying_notice(&opts.environment, worker, i + 1, order.len())
905 );
906 }
907 match deploy_one(
908 &probe.provenance,
909 project_root,
910 &workers_dir,
911 worker,
912 &resources[worker],
913 &resources,
914 &mut lock,
915 &lock_path,
916 &mut attempted_queues,
917 &mut Secrets {
918 source: &secret_source,
919 force: opts.force,
920 edge_keys: &edge_keys,
921 foreign_keys: &foreign_keys,
922 foreign_secrets: &foreign_secrets,
923 listed: &listed,
924 resolved: &mut resolved_secrets,
925 with_upload: secrets_file_supported(&probe),
926 },
927 &opts.wrangler_args,
928 &opts.environment,
929 live_kv_ids.as_ref(),
930 ) {
931 Ok(Pushed::Ok) => {
932 let provided = read_contracts_manifest(&workers_dir.join(worker))
939 .map(|m| Some(m.provides))
940 .unwrap_or(None);
941 lock.record_deployed(&opts.environment, worker, provided);
942 record_pushed_tombstones(&mut lock, &opts.environment, worker, &resources[worker]);
943 lock.forget_undeclared_ratelimits(
946 &opts.environment,
947 worker,
948 &resources[worker].ratelimits,
949 );
950 if let Ok(m) = read_contracts_manifest(&workers_dir.join(worker)) {
954 lock.record_expects(&opts.environment, worker, m.expects);
955 }
956 lock.record_foreign(
960 &opts.environment,
961 worker,
962 read_contracts_manifest(&workers_dir.join(worker))
963 .map(|m| m.foreign)
964 .unwrap_or_default(),
965 );
966 for caller in &resources[worker].foreign_callers {
969 if let Some(ForeignKey::Set {
970 value,
971 minted: true,
972 }) = foreign_keys.get(&(worker.clone(), caller.binding.clone()))
973 {
974 eprint!(
975 "{}",
976 foreign_hand_off(&opts.environment, worker, caller, value)
977 );
978 }
979 }
980 lock.record_assets(
982 &opts.environment,
983 worker,
984 served_assets(&workers_dir.join(worker)),
985 );
986 if let Err(e) = write_lock(&lock_path, &lock) {
987 eprint!(
988 "{}",
989 unrecorded_deploy_failure(
990 &opts.environment,
991 worker,
992 &lock_path.display().to_string(),
993 &e.to_string()
994 )
995 );
996 return ExitCode::FAILURE;
997 }
998 }
999 Ok(Pushed::Interrupted) => return ExitCode::SUCCESS,
1005 Err(f) => {
1006 eprintln!("bynk: {}", f.message);
1007 eprint!("{}", stopped_report(&opts.environment, &order[i + 1..]));
1014 if let Some(hint) =
1019 interrupted_rotation_hint(&opts.environment, &edge_keys, &order[..i])
1020 {
1021 eprint!("{hint}");
1022 }
1023 for caller in &resources[worker].foreign_callers {
1027 if let Some(ForeignKey::Set {
1028 value,
1029 minted: true,
1030 }) = foreign_keys.get(&(worker.clone(), caller.binding.clone()))
1031 {
1032 eprint!(
1033 "{}",
1034 foreign_binding_maybe_set_notice(&opts.environment, worker, caller)
1035 );
1036 eprint!(
1037 "{}",
1038 foreign_hand_off(&opts.environment, worker, caller, value)
1039 );
1040 }
1041 }
1042 return ExitCode::from(f.code);
1044 }
1045 }
1046 }
1047
1048 if opts.prune && plan.orphans.has_prunable() {
1053 if !confirm_prune(opts.yes, &plan.orphans, &opts.environment) {
1054 return ExitCode::FAILURE;
1055 }
1056 if let Err(e) = prune_orphans(
1057 &probe.provenance,
1058 project_root,
1059 &mut lock,
1060 &lock_path,
1061 &opts.environment,
1062 &plan.orphans,
1063 ) {
1064 eprintln!("bynk: {}", e.message);
1065 return ExitCode::from(e.code);
1066 }
1067 }
1068 ExitCode::SUCCESS
1069}
1070
1071pub(crate) fn foreign_worker_warnings(
1078 order: &[String],
1079 resources: &BTreeMap<String, Resources>,
1080 mut presence: impl FnMut(&str) -> WorkerPresence,
1081) -> Vec<String> {
1082 let mut bound: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1083 for worker in order {
1084 for f in &resources[worker].foreign_services {
1085 bound.entry(f.worker.as_str()).or_default().push(worker);
1086 }
1087 }
1088 let mut out = Vec::new();
1089 for (name, by) in bound {
1090 let by = by
1091 .iter()
1092 .map(|w| format!("`{w}`"))
1093 .collect::<Vec<_>>()
1094 .join(", ");
1095 match presence(name) {
1096 WorkerPresence::Present => {}
1097 WorkerPresence::Missing => out.push(format!(
1098 "bynk: warning: {by} binds to Worker `{name}`, which this account does not have — \
1099 Cloudflare rejects a Service Binding to a missing Worker at upload, so deploy `{name}` first."
1100 )),
1101 WorkerPresence::Unknown(said) => out.push(format!(
1102 "bynk: warning: could not check that Worker `{name}` (bound by {by}) exists: {said}"
1103 )),
1104 }
1105 }
1106 out
1107}
1108
1109pub(crate) fn interrupted_rotation_hint(
1117 environment: &str,
1118 edge_keys: &BTreeMap<Edge, EdgeKey>,
1119 completed: &[String],
1120) -> Option<String> {
1121 let done = |w: &String| completed.contains(w);
1122 let split: Vec<String> = edge_keys
1123 .iter()
1124 .filter(|(edge, key)| {
1125 matches!(key, EdgeKey::Set { rotating: true, .. })
1126 && !(done(&edge.caller) && done(&edge.callee))
1127 })
1128 .map(|(edge, _)| edge.describe(environment))
1129 .collect();
1130 if split.is_empty() {
1131 return None;
1132 }
1133 Some(format!(
1134 "bynk: this run was setting new keys on {} and stopped before both ends of each were deployed, so each end may hold a different key and calls along it are refused.\n \
1135 Rerun `{} --force` (the whole project) to set one key on both ends; a plain rerun would keep both.\n",
1136 split.join(", "),
1137 deploy_command(environment)
1138 ))
1139}
1140
1141pub(crate) fn contract_skew_refusal(
1146 environment: &str,
1147 worker: &str,
1148 skews: &[ContractSkew],
1149) -> String {
1150 let mut out = format!(
1151 "bynk: `{}` was compiled against a contract its live dependencies no longer provide ({CONTRACT_SKEW}):\n",
1152 deployed_worker_name(environment, worker)
1153 );
1154 for s in skews {
1155 out.push_str(&format!(
1156 " {}.{} — compiled against {}, live is {}\n",
1157 s.dependency, s.service, s.expected, s.live
1158 ));
1159 }
1160 out.push_str(
1161 " Deploying this would ship a caller its callee rejects (409 ContractMismatch) on every call.\n",
1162 );
1163 out.push_str(&format!(
1164 " Deploy the whole project (`{}`) so both sides move together.\n",
1165 deploy_command(environment)
1166 ));
1167 out
1168}
1169
1170pub(crate) fn unknown_edge_key_notice(
1174 environment: &str,
1175 worker: &str,
1176 key: &str,
1177 edge: &Edge,
1178) -> String {
1179 format!(
1180 "bynk: could not ask Cloudflare whether `{}` holds {key}; assuming it does. If it does not, the edge {} refuses every call — `{} --force` sets a fresh key on every edge.\n",
1181 deployed_worker_name(environment, worker),
1182 edge.describe(environment),
1183 deploy_command(environment)
1184 )
1185}
1186
1187pub(crate) fn deploying_notice(environment: &str, worker: &str, n: usize, total: usize) -> String {
1190 format!(
1191 "bynk: deploying `{}` ({n}/{total})…\n",
1192 deployed_worker_name(environment, worker)
1193 )
1194}
1195
1196pub(crate) fn unrecorded_deploy_failure(
1199 environment: &str,
1200 worker: &str,
1201 lock_path: &str,
1202 error: &str,
1203) -> String {
1204 format!(
1205 "bynk: deployed `{}` but could not record it in {lock_path}: {error}\n",
1206 deployed_worker_name(environment, worker)
1207 )
1208}
1209
1210pub(crate) fn foreign_binding_refusal(
1214 environment: &str,
1215 worker: &str,
1216 caller: &ForeignCaller,
1217 reason: &str,
1218) -> String {
1219 format!(
1220 "bynk: `{}` (the foreign caller `{}` of `{}`): {reason}\n",
1221 caller.binding,
1222 caller.actor,
1223 deployed_worker_name(environment, worker)
1224 )
1225}
1226
1227pub(crate) fn foreign_binding_maybe_set_notice(
1231 environment: &str,
1232 worker: &str,
1233 caller: &ForeignCaller,
1234) -> String {
1235 let worker = deployed_worker_name(environment, worker);
1236 format!(
1237 "bynk: `{worker}` may already hold the {} minted for its foreign caller `{}`. If `wrangler secret list --name {worker}` shows it, hand this off; if not, the next deploy mints another.\n",
1238 caller.binding, caller.actor
1239 )
1240}
1241
1242pub(crate) fn wrangler_floor_refusal(
1256 probe: &probe::Probe,
1257 order: &[String],
1258 resources: &BTreeMap<String, Resources>,
1259) -> Option<String> {
1260 let version = probe
1261 .version
1262 .filter(|_| doctor::wrangler_below_min(probe))?;
1263 let worker = order
1264 .iter()
1265 .find(|worker| !resources[*worker].durable_objects.is_empty())?;
1266 Some(format!(
1267 "bynk: wrangler {version} is older than {}, the first that reads the Durable Object \
1268 `exports` `{worker}` declares; the push would fail. Upgrade with `{}`.",
1269 bynk_emit::WRANGLER_MIN,
1270 doctor::wrangler_upgrade_remedy(probe)
1271 ))
1272}
1273
1274pub fn preflight_failure_message(report: &Report) -> String {
1275 format!(
1276 "bynk: environment not ready for `deploy` — see below.\n\n{}",
1277 report::render(report, Format::Human)
1278 )
1279}
1280
1281pub(crate) fn absent_dependencies_refusal(
1289 environment: &str,
1290 worker: &str,
1291 absent: &[String],
1292) -> String {
1293 format!(
1294 "bynk: `{}` binds to {}, which {} never been deployed — a Service Binding to a Worker that does not exist fails at upload.\n Deploy the whole project once (`{}`) to bring the topology up.\n",
1295 deployed_worker_name(environment, worker),
1296 absent
1297 .iter()
1298 .map(|a| format!("`{}`", deployed_worker_name(environment, a)))
1299 .collect::<Vec<_>>()
1300 .join(", "),
1301 if absent.len() == 1 { "has" } else { "have" },
1302 deploy_command(environment)
1303 )
1304}
1305
1306pub(crate) fn callee_contract_skew_refusal(
1313 environment: &str,
1314 worker: &str,
1315 skews: &[CalleeSkew],
1316) -> String {
1317 let mut out = format!(
1318 "bynk: `{}` no longer provides the contract its live callers were compiled against ({CONTRACT_SKEW}):\n",
1319 deployed_worker_name(environment, worker)
1320 );
1321 for s in skews {
1322 out.push_str(&format!(
1323 " `{}` calls {}.{} — compiled against {}, this build provides {}\n",
1324 deployed_worker_name(environment, &s.caller),
1325 s.dependency,
1326 s.service,
1327 s.expected,
1328 s.provided
1329 ));
1330 }
1331 out.push_str(
1332 " Deploying this would ship a callee that rejects its live callers (409 ContractMismatch) on every call.\n",
1333 );
1334 out.push_str(&format!(
1335 " Deploy the whole project (`{}`) so both sides move together.\n",
1336 deploy_command(environment)
1337 ));
1338 out
1339}
1340
1341pub(crate) fn orphaned_caller_skew_refusal(
1350 environment: &str,
1351 worker: &str,
1352 skews: &[CalleeSkew],
1353) -> String {
1354 let mut out = format!(
1355 "bynk: `{}` no longer provides the contract a live Worker this project no longer builds was compiled against ({CONTRACT_SKEW}):\n",
1356 deployed_worker_name(environment, worker)
1357 );
1358 for s in skews {
1359 out.push_str(&format!(
1360 " `{}` (orphaned) calls {}.{} — compiled against {}, this build provides {}\n",
1361 deployed_worker_name(environment, &s.caller),
1362 s.dependency,
1363 s.service,
1364 s.expected,
1365 s.provided
1366 ));
1367 }
1368 out.push_str(
1369 " Deploying this would ship a callee that rejects it (409 ContractMismatch) on every call, and a whole-project deploy would not move it: that reports an orphaned Worker but never redeploys or deletes it, and `--prune` never deletes a Worker either.\n",
1370 );
1371 let mut callers: Vec<&str> = skews.iter().map(|s| s.caller.as_str()).collect();
1372 callers.dedup();
1373 out.push_str(&format!(
1374 " If it is unused, delete it ({}) and remove its entry ({}) from {LOCK_FILE}. If it is still needed, restore its context and deploy the whole project (`{}`).\n",
1375 callers
1376 .iter()
1377 .map(|c| format!(
1378 "`npx wrangler delete --name {}`",
1379 deployed_worker_name(environment, c)
1380 ))
1381 .collect::<Vec<_>>()
1382 .join(", "),
1383 callers
1384 .iter()
1385 .map(|c| format!("`[environments.{environment}.workers.{c}]`"))
1386 .collect::<Vec<_>>()
1387 .join(", "),
1388 deploy_command(environment)
1389 ));
1390 out
1391}
1392
1393pub(crate) fn unrecorded_caller_warning(environment: &str, worker: &str, caller: &str) -> String {
1397 format!(
1398 "bynk: warning: could not check that `{}` still provides what the live `{}` was compiled against — its deploy record predates recorded expectations ({CONTRACT_SKEW}). A whole-project deploy (`{}`) records them.\n",
1399 deployed_worker_name(environment, worker),
1400 deployed_worker_name(environment, caller),
1401 deploy_command(environment)
1402 )
1403}
1404
1405pub(crate) fn stopped_report(environment: &str, rest: &[String]) -> String {
1414 if rest.is_empty() {
1415 return String::new();
1416 }
1417 format!(
1418 "bynk: stopping — {} not deployed: {}. Re-run `{}` to resume; what already landed is kept.\n",
1419 if rest.len() == 1 {
1420 "1 more context was".to_string()
1421 } else {
1422 format!("{} further contexts were", rest.len())
1423 },
1424 rest.iter()
1425 .map(|worker| deployed_worker_name(environment, worker))
1426 .collect::<Vec<_>>()
1427 .join(", "),
1428 deploy_command(environment)
1429 )
1430}
1431
1432pub(crate) fn plan_report(plan: &Plan<'_>, format: DeployFormat) -> String {
1436 match format {
1437 DeployFormat::Short => {
1438 let mut out = String::new();
1439 for kv in &plan.shown_orphans.kv {
1444 match &kv.binding {
1445 None => out.push_str(&format!("orphan kv {}\n", kv.worker)),
1446 Some(binding) => {
1447 out.push_str(&format!("orphan kv {} {binding}\n", kv.worker));
1448 }
1449 }
1450 }
1451 for r2 in &plan.shown_orphans.r2 {
1452 out.push_str(&format!(
1453 "orphan r2 {} {} ({}{}; not deleted)\n",
1454 r2.worker,
1455 r2.binding,
1456 r2.bucket,
1457 r2.jurisdiction
1458 .as_deref()
1459 .map(|j| format!(", jurisdiction {j}"))
1460 .unwrap_or_default()
1461 ));
1462 }
1463 for worker in &plan.shown_orphans.workers {
1464 out.push_str(&format!("orphan worker {worker}\n"));
1465 }
1466 for queue in &plan.shown_orphans.queues {
1467 out.push_str(&format!("orphan queue {queue}\n"));
1468 }
1469 for context in &plan.contexts {
1470 for kv in &context.kv {
1471 if is_bare_kv(kv.binding) {
1473 out.push_str(&format!("kv {} {}\n", kv.action, kv.namespace));
1474 } else {
1475 out.push_str(&format!(
1476 "kv {} {} ({})\n",
1477 kv.action, kv.namespace, kv.binding
1478 ));
1479 }
1480 }
1481 for r2 in &context.r2 {
1482 out.push_str(&format!("r2 {} {} ({})", r2.action, r2.bucket, r2.binding));
1483 if let Some(jurisdiction) = r2.jurisdiction {
1484 out.push_str(&format!(" in jurisdiction {jurisdiction}"));
1485 }
1486 if r2.action == "recreate" {
1487 out.push_str(" — the old bucket and its objects stay where they are");
1488 }
1489 out.push('\n');
1490 }
1491 for limiter in &context.ratelimits {
1492 match &limiter.namespace_id {
1493 Some(id) => out.push_str(&format!(
1494 "ratelimit {} {} (namespace {id})\n",
1495 limiter.action, limiter.binding
1496 )),
1497 None => out.push_str(&format!(
1498 "ratelimit {} {}\n",
1499 limiter.action, limiter.binding
1500 )),
1501 }
1502 }
1503 for queue in &context.queues {
1504 out.push_str(&format!("queue {} {}\n", queue.action, queue.queue));
1505 }
1506 for service in &context.foreign_services {
1509 out.push_str(&format!(
1510 "service {} {} (foreign Worker; not provisioned)\n",
1511 service.binding, service.worker
1512 ));
1513 }
1514 for object in &context.durable_objects {
1521 out.push_str(&format!(
1522 "durable object {} ({}; advisory — {} reconciles it)\n",
1523 object.class, object.storage, object.reconciled_by
1524 ));
1525 }
1526 for rename in &context.renamed_durable_objects {
1529 out.push_str(&if rename.already_applied {
1530 format!(
1531 "rename durable object {} → {} (already applied by an earlier deploy; the tombstone stays until Cloudflare reports it stale)\n",
1532 rename.from, rename.to
1533 )
1534 } else {
1535 format!(
1536 "rename durable object {} → {} (moves its stored data)\n",
1537 rename.from, rename.to
1538 )
1539 });
1540 }
1541 for deletion in &context.deleted_durable_objects {
1542 out.push_str(&if deletion.already_applied {
1543 format!(
1544 "delete durable object {} (already deleted by an earlier deploy; the tombstone stays until Cloudflare reports it stale)\n",
1545 deletion.class
1546 )
1547 } else {
1548 format!(
1549 "delete durable object {} (all stored data; irreversible)\n",
1550 deletion.class
1551 )
1552 });
1553 }
1554 if !context.secrets_complete {
1558 out.push_str(&format!(
1559 "secrets incomplete {} (reads at least one name the compiler cannot see)\n",
1560 context.worker
1561 ));
1562 }
1563 for secret in &context.secrets {
1567 out.push_str(&format!(
1568 "secret {} {} ({})\n",
1569 secret.action,
1570 secret.name,
1571 secret.origin.label()
1572 ));
1573 }
1574 out.push_str(&format!("{} {}\n", context.action, context.worker));
1575 }
1576 if plan.order.len() > 1 {
1580 out.push_str(&format!("order {}\n", plan.order.join(" → ")));
1581 }
1582 out
1583 }
1584 DeployFormat::Json => {
1585 format!(
1586 "{}\n",
1587 serde_json::to_string_pretty(plan).expect("plan serialises")
1588 )
1589 }
1590 }
1591}
1592
1593fn print_plan(plan: &Plan<'_>, format: DeployFormat) {
1594 print!("{}", plan_report(plan, format));
1595}
1596
1597pub(crate) fn derive_plan<'a>(
1605 order: &'a [String],
1606 resources: &'a BTreeMap<String, Resources>,
1607 lock: &DeployLock,
1608 secrets: &SecretSource,
1611 force: bool,
1612 environment: &'a str,
1613) -> Plan<'a> {
1614 let edges = project_edges(resources);
1615 let foreign_secrets: BTreeSet<String> = resources
1616 .values()
1617 .flat_map(|r| r.foreign_callers.iter().map(|f| f.binding.clone()))
1618 .collect();
1619 let orphans = find_orphans(lock, environment, resources);
1620 Plan {
1621 environment,
1622 shown_orphans: orphans.as_named_in(environment),
1623 orphans,
1624 order: order
1625 .iter()
1626 .map(|worker| deployed_worker_name(environment, worker))
1627 .collect(),
1628 contexts: order
1629 .iter()
1630 .map(|worker| {
1631 let declared = &resources[worker];
1632 ContextPlan {
1633 worker: deployed_worker_name(environment, worker),
1634 kv: kv_bindings(declared)
1635 .map(|binding| PlanKv {
1636 binding,
1637 action: if recorded_kv_binding(lock, worker, binding, environment)
1638 .is_some()
1639 {
1640 "reuse"
1641 } else {
1642 "create"
1643 },
1644 namespace: kv_title(worker, binding, environment),
1645 })
1646 .collect(),
1647 r2: declared
1648 .r2_buckets
1649 .iter()
1650 .map(|bucket| PlanR2 {
1651 binding: &bucket.binding,
1652 action: r2_action(lock, worker, bucket, environment),
1653 bucket: r2_bucket_name(environment, &bucket.name),
1654 jurisdiction: bucket.jurisdiction.as_deref(),
1655 })
1656 .collect(),
1657 ratelimits: declared
1658 .ratelimits
1659 .iter()
1660 .map(|binding| {
1661 let recorded = recorded_ratelimit(lock, worker, binding, environment);
1662 PlanRateLimit {
1663 binding,
1664 action: if recorded.is_some() {
1665 "reuse"
1666 } else {
1667 "allocate"
1668 },
1669 namespace_id: recorded.map(str::to_string),
1670 }
1671 })
1672 .collect(),
1673 queues: declared
1674 .queues
1675 .iter()
1676 .map(|queue| PlanQueue {
1677 action: if lock.has_queue(environment, queue) {
1678 "reuse"
1679 } else {
1680 "create"
1681 },
1682 queue: env_qualify(environment, queue),
1683 })
1684 .collect(),
1685 durable_objects: declared
1686 .durable_objects
1687 .iter()
1688 .map(|object| PlanDurableObject {
1689 class: &object.class,
1690 storage: &object.storage,
1691 reconciled_by: "Cloudflare",
1692 })
1693 .collect(),
1694 renamed_durable_objects: declared
1695 .renamed_durable_objects
1696 .iter()
1697 .map(|(from, to)| PlanDurableObjectRename {
1698 from,
1699 to,
1700 already_applied: lock.has_renamed_class(environment, worker, from, to),
1701 })
1702 .collect(),
1703 deleted_durable_objects: declared
1704 .deleted_durable_objects
1705 .iter()
1706 .map(|class| PlanDeletion {
1707 class: qualified_class(worker, class),
1708 already_applied: lock.has_deleted_class(environment, worker, class),
1709 })
1710 .collect(),
1711 secrets: wanted_secrets(
1712 &declared.declared_secrets,
1713 &declared.read_secrets,
1714 secrets,
1715 )
1716 .into_iter()
1717 .filter(|w| !foreign_secrets.contains(&w.name))
1719 .chain(
1721 edge_keys_of(worker, &edges)
1722 .into_iter()
1723 .map(|name| WantedSecret {
1724 name,
1725 origin: Origin::Managed,
1726 }),
1727 )
1728 .chain(declared.foreign_callers.iter().map(|f| WantedSecret {
1731 name: f.binding.clone(),
1732 origin: Origin::Managed,
1733 }))
1734 .map(|want| PlanSecret {
1735 name: want.name,
1736 origin: want.origin,
1737 action: if force { "overwrite" } else { "set" },
1741 })
1742 .collect(),
1743 secrets_complete: declared.reads_complete,
1744 action: if lock.is_deployed(environment, worker) {
1745 "redeploy"
1746 } else {
1747 "deploy"
1748 },
1749 binds_to: declared
1750 .binds_to
1751 .iter()
1752 .map(|target| deployed_worker_name(environment, target))
1753 .collect(),
1754 foreign_services: declared
1755 .foreign_services
1756 .iter()
1757 .map(|f| PlanForeignService {
1758 binding: &f.binding,
1759 worker: &f.worker,
1760 })
1761 .collect(),
1762 }
1763 })
1764 .collect(),
1765 }
1766}
1767
1768pub(crate) fn kv_title_collision(
1795 resources: &BTreeMap<String, Resources>,
1796 lock: &DeployLock,
1797 environment: &str,
1798) -> Option<String> {
1799 let mut seen: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1800 for (worker, declared) in resources {
1801 for binding in kv_bindings(declared) {
1802 let title = kv_title(worker, binding, environment);
1803 if let Some((first_worker, first_binding)) =
1804 seen.insert(title.clone(), (worker.as_str(), binding))
1805 {
1806 return Some(format!(
1807 "{} and {} would both be titled `{title}` on the account; rename a capability or context so they differ",
1808 kv_namespace_noun(first_worker, first_binding),
1809 kv_namespace_noun(worker, binding)
1810 ));
1811 }
1812 }
1813 }
1814
1815 let creates: Vec<(&str, &str)> = resources
1817 .iter()
1818 .flat_map(|(worker, declared)| {
1819 kv_bindings(declared).map(move |binding| (worker.as_str(), binding))
1820 })
1821 .filter(|(worker, binding)| {
1822 recorded_kv_binding(lock, worker, binding, environment).is_none()
1823 })
1824 .collect();
1825 cross_environment_title_clash(resources, lock, environment, &creates, None)
1826}
1827
1828pub(crate) fn recreate_refusal(plan: &Plan<'_>, yes: bool) -> Option<&'static str> {
1833 (!yes && plan.contexts.iter().any(|c| c.r2.iter().any(|b| b.action == "recreate")))
1834 .then_some("a changed `@jurisdiction` makes a new R2 bucket and leaves the old one, with its objects, where it is (the plan reports it as an orphan); move the data, then re-run with --yes")
1835}
1836
1837pub(crate) fn r2_action(
1842 lock: &DeployLock,
1843 worker: &str,
1844 bucket: &R2Decl,
1845 environment: &str,
1846) -> &'static str {
1847 match recorded_r2(lock, worker, &bucket.binding, environment) {
1848 None => "create",
1849 Some(recorded)
1850 if recorded.name == r2_bucket_name(environment, &bucket.name)
1851 && recorded.jurisdiction == bucket.jurisdiction =>
1852 {
1853 "reuse"
1854 }
1855 Some(recorded) if recorded.name == r2_bucket_name(environment, &bucket.name) => "recreate",
1856 Some(_) => "create",
1857 }
1858}
1859
1860pub(crate) fn r2_bucket_problem(
1866 resources: &BTreeMap<String, Resources>,
1867 lock: &DeployLock,
1868 environment: &str,
1869) -> Option<String> {
1870 let mut seen: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1871 for (worker, declared) in resources {
1872 for bucket in &declared.r2_buckets {
1873 let name = r2_bucket_name(environment, &bucket.name);
1874 if let Some(why) = r2_bucket_name_problem(&name) {
1875 return Some(format!(
1876 "R2 bucket `{name}` (`{}` of `{worker}`) cannot be created: {why}; rename the capability or the context",
1877 bucket.binding
1878 ));
1879 }
1880 if let Some((other_worker, other_binding)) =
1881 seen.insert(name.clone(), (worker, &bucket.binding))
1882 {
1883 return Some(format!(
1884 "`{}` of `{worker}` and `{other_binding}` of `{other_worker}` would both be the R2 bucket `{name}`; bucket names are scoped to the account, so rename one capability",
1885 bucket.binding
1886 ));
1887 }
1888 for (other_env, env) in &lock.environments {
1889 let clash = env
1890 .r2_instances
1891 .iter()
1892 .flat_map(|(w, instances)| instances.iter().map(move |(b, r)| (w, b, r)))
1893 .find(|(w, b, r)| {
1894 r.name == name
1895 && (other_env != environment
1896 || w.as_str() != worker
1897 || **b != bucket.binding)
1898 });
1899 if let Some((other_worker, other_binding, _)) = clash {
1900 return Some(format!(
1901 "`{}` of `{worker}` would be the R2 bucket `{name}`, which `{other_binding}` of `{other_worker}` already holds in environment `{other_env}`; bucket names are scoped to the account, so rename one capability",
1902 bucket.binding
1903 ));
1904 }
1905 }
1906 }
1907 }
1908 None
1909}
1910
1911pub(crate) fn cross_environment_title_clash(
1926 resources: &BTreeMap<String, Resources>,
1927 lock: &DeployLock,
1928 environment: &str,
1929 creates: &[(&str, &str)],
1930 live_kv_ids: Option<&BTreeSet<String>>,
1931) -> Option<String> {
1932 if creates.is_empty() {
1933 return None;
1934 }
1935 let is_live = |id: &str| live_kv_ids.is_none_or(|live| live.contains(id));
1936 let declared = || {
1937 resources.iter().flat_map(|(worker, declared)| {
1938 kv_bindings(declared).map(move |binding| (worker.as_str(), binding))
1939 })
1940 };
1941 for (other, recorded) in &lock.environments {
1942 if other == environment {
1943 continue;
1944 }
1945 let held = recorded
1948 .kv
1949 .iter()
1950 .filter(|(_, kv)| is_live(&kv.id))
1951 .map(|(worker, _)| {
1952 (
1953 worker.as_str(),
1954 bynk_emit::emitter::wrangler::KV_BINDING_NAME,
1955 )
1956 })
1957 .chain(
1958 recorded
1959 .kv_instances
1960 .iter()
1961 .flat_map(|(worker, instances)| {
1962 instances
1963 .iter()
1964 .filter(|(_, kv)| is_live(&kv.id))
1965 .map(move |(binding, _)| (worker.as_str(), binding.as_str()))
1966 }),
1967 );
1968 let mut taken: BTreeMap<String, (&str, &str)> = BTreeMap::new();
1969 for (worker, binding) in held.chain(declared()) {
1970 taken
1971 .entry(kv_title(worker, binding, other))
1972 .or_insert((worker, binding));
1973 }
1974 for &(worker, binding) in creates {
1975 let title = kv_title(worker, binding, environment);
1976 if let Some(&(other_worker, other_binding)) = taken.get(&title) {
1977 return Some(format!(
1978 "{} in {} and {} in {} would both be titled `{title}` on the account, which holds one namespace per title; rename a capability or context so they differ",
1979 kv_namespace_noun(worker, binding),
1980 environment_label(environment),
1981 kv_namespace_noun(other_worker, other_binding),
1982 environment_label(other),
1983 ));
1984 }
1985 }
1986 }
1987 None
1988}
1989
1990fn environment_label(environment: &str) -> String {
1993 if environment == "default" {
1994 "the default environment".to_string()
1995 } else {
1996 format!("`--env {environment}`")
1997 }
1998}
1999
2000fn first_unrecorded_kv<'a>(
2005 order: &'a [String],
2006 resources: &'a BTreeMap<String, Resources>,
2007 lock: &DeployLock,
2008 environment: &str,
2009) -> Option<(&'a str, &'a str)> {
2010 order.iter().find_map(|worker| {
2011 kv_bindings(&resources[worker])
2012 .find(|binding| recorded_kv_binding(lock, worker, binding, environment).is_none())
2013 .map(|binding| (worker.as_str(), binding))
2014 })
2015}
2016
2017fn first_unrecorded_ratelimit<'a>(
2020 order: &'a [String],
2021 resources: &'a BTreeMap<String, Resources>,
2022 lock: &DeployLock,
2023 environment: &str,
2024) -> Option<(&'a str, &'a str)> {
2025 order.iter().find_map(|worker| {
2026 resources[worker]
2027 .ratelimits
2028 .iter()
2029 .find(|binding| recorded_ratelimit(lock, worker, binding, environment).is_none())
2030 .map(|binding| (worker.as_str(), binding.as_str()))
2031 })
2032}
2033
2034pub(crate) fn requires_interactive_confirmation(yes: bool, stdin_is_terminal: bool) -> bool {
2035 !yes && stdin_is_terminal
2036}
2037
2038fn is_ci() -> bool {
2039 std::env::var_os("CI").is_some_and(|value| value != "false")
2040}
2041
2042fn confirm(yes: bool) -> bool {
2043 if yes {
2044 return true;
2045 }
2046 if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
2047 eprintln!("bynk: refusing to mutate in a non-interactive session without --yes");
2048 return false;
2049 }
2050 eprint!("Deploy to Cloudflare? [y/N] ");
2051 let _ = io::stderr().flush();
2052 let mut answer = String::new();
2053 io::stdin().read_line(&mut answer).is_ok()
2054 && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
2055}
2056
2057#[cfg(test)]
2058pub(crate) mod tests {
2059 use super::*;
2060 use crate::deploy::config::tests::project;
2061 use crate::deploy::graph::tests::graph;
2062 use crate::deploy::ledger::tests::{lock_with_deployed, with_kv, with_queue};
2063
2064 fn names(v: &[&str]) -> Vec<String> {
2065 v.iter().map(|s| s.to_string()).collect()
2066 }
2067
2068 pub(crate) fn plan_of<'a>(
2072 order: &'a [String],
2073 resources: &'a BTreeMap<String, Resources>,
2074 lock: &DeployLock,
2075 ) -> Plan<'a> {
2076 derive_plan(
2077 order,
2078 resources,
2079 lock,
2080 &SecretSource::default(),
2081 false,
2082 "default",
2083 )
2084 }
2085
2086 #[test]
2091 fn the_plan_allocates_each_rate_limiter_then_reuses_its_id() {
2092 let declared = project(vec![(
2093 "shop",
2094 Resources::default().ratelimits(&["RATELIMIT_LOGIN"]),
2095 )]);
2096 let order = names(&["shop"]);
2097 let fresh = plan_of(&order, &declared, &DeployLock::default());
2098 let report = plan_report(&fresh, DeployFormat::Short);
2099 assert!(
2100 report.contains("ratelimit allocate RATELIMIT_LOGIN\n"),
2101 "{report}"
2102 );
2103 assert_eq!(
2104 first_unrecorded_ratelimit(&order, &declared, &DeployLock::default(), "default"),
2105 Some(("shop", "RATELIMIT_LOGIN")),
2106 "what CI refuses to allocate"
2107 );
2108 let mut lock = DeployLock::default();
2109 lock.allocate_ratelimit("default", "shop", "RATELIMIT_LOGIN");
2110 let again = plan_of(&order, &declared, &lock);
2111 let report = plan_report(&again, DeployFormat::Short);
2112 let id = crate::deploy::ledger::ratelimit_seed("default", "shop", "RATELIMIT_LOGIN");
2113 assert!(
2114 report.contains(&format!(
2115 "ratelimit reuse RATELIMIT_LOGIN (namespace {id})\n"
2116 )),
2117 "{report}"
2118 );
2119 assert_eq!(
2120 first_unrecorded_ratelimit(&order, &declared, &lock, "default"),
2121 None
2122 );
2123 }
2124
2125 fn shop_with_buckets() -> BTreeMap<String, Resources> {
2126 project(vec![(
2127 "shop",
2128 Resources::default().r2_buckets(&[
2129 ("R2_ARCHIVES", "shop-archives", None),
2130 ("R2_INDEXES", "shop-indexes", Some("eu")),
2131 ]),
2132 )])
2133 }
2134
2135 #[test]
2136 fn the_plan_creates_each_bucket_then_reuses_it() {
2137 let declared = shop_with_buckets();
2138 let order = names(&["shop"]);
2139 let fresh = plan_of(&order, &declared, &DeployLock::default());
2140 let r2: Vec<(&str, &str, &str)> = fresh.contexts[0]
2141 .r2
2142 .iter()
2143 .map(|b| (b.binding, b.action, b.bucket.as_str()))
2144 .collect();
2145 assert_eq!(
2146 r2,
2147 [
2148 ("R2_ARCHIVES", "create", "shop-archives"),
2149 ("R2_INDEXES", "create", "shop-indexes")
2150 ]
2151 );
2152 let mut lock = DeployLock::default();
2153 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2154 lock.record_r2("default", "shop", "R2_INDEXES", "shop-indexes", Some("eu"));
2155 let again = plan_of(&order, &declared, &lock);
2156 assert!(again.contexts[0].r2.iter().all(|b| b.action == "reuse"));
2157 }
2158
2159 #[test]
2160 fn a_named_environment_plans_its_own_qualified_buckets() {
2161 let declared = shop_with_buckets();
2162 let order = names(&["shop"]);
2163 let plan = derive_plan(
2164 &order,
2165 &declared,
2166 &DeployLock::default(),
2167 &SecretSource::default(),
2168 false,
2169 "staging",
2170 );
2171 assert_eq!(plan.contexts[0].r2[0].bucket, "shop-archives-staging");
2172 let report = plan_report(&plan, DeployFormat::Short);
2173 assert!(
2174 report.contains("r2 create shop-archives-staging (R2_ARCHIVES)\n"),
2175 "{report}"
2176 );
2177 assert!(
2178 report.contains("r2 create shop-indexes-staging (R2_INDEXES) in jurisdiction eu\n"),
2179 "{report}"
2180 );
2181 }
2182
2183 #[test]
2184 fn a_changed_jurisdiction_is_a_recreate_that_says_the_old_bucket_stays() {
2185 let declared = shop_with_buckets();
2186 let mut lock = DeployLock::default();
2187 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2188 lock.record_r2(
2189 "default",
2190 "shop",
2191 "R2_INDEXES",
2192 "shop-indexes",
2193 Some("fedramp"),
2194 );
2195 let order = names(&["shop"]);
2196 let plan = plan_of(&order, &declared, &lock);
2197 assert_eq!(plan.contexts[0].r2[0].action, "reuse");
2198 assert_eq!(plan.contexts[0].r2[1].action, "recreate");
2199 let report = plan_report(&plan, DeployFormat::Short);
2200 assert!(
2201 report.contains("r2 recreate shop-indexes (R2_INDEXES) in jurisdiction eu — the old bucket and its objects stay where they are"),
2202 "{report}"
2203 );
2204 }
2205
2206 #[test]
2207 fn a_recreate_without_yes_is_refused_and_with_yes_is_not() {
2208 let declared = shop_with_buckets();
2209 let mut lock = DeployLock::default();
2210 lock.record_r2("default", "shop", "R2_ARCHIVES", "shop-archives", None);
2211 lock.record_r2(
2212 "default",
2213 "shop",
2214 "R2_INDEXES",
2215 "shop-indexes",
2216 Some("fedramp"),
2217 );
2218 let order = names(&["shop"]);
2219 let plan = plan_of(&order, &declared, &lock);
2220 assert!(recreate_refusal(&plan, false).is_some());
2221 assert!(recreate_refusal(&plan, true).is_none());
2222 let fresh = plan_of(&order, &declared, &DeployLock::default());
2224 assert!(recreate_refusal(&fresh, false).is_none());
2225 }
2226
2227 #[test]
2228 fn a_bucket_a_changed_jurisdiction_left_behind_is_reported_as_an_orphan() {
2229 let declared = shop_with_buckets();
2230 let mut lock = DeployLock::default();
2231 lock.record_r2(
2232 "default",
2233 "shop",
2234 "R2_INDEXES",
2235 "shop-indexes",
2236 Some("fedramp"),
2237 );
2238 lock.record_r2("default", "shop", "R2_INDEXES", "shop-indexes", Some("eu"));
2239 let order = names(&["shop"]);
2240 let report = plan_report(&plan_of(&order, &declared, &lock), DeployFormat::Short);
2241 assert!(
2242 report.contains(
2243 "orphan r2 shop R2_INDEXES (shop-indexes, jurisdiction fedramp; not deleted)\n"
2244 ),
2245 "{report}"
2246 );
2247 }
2248
2249 #[test]
2250 fn an_orphaned_bucket_is_shown_with_its_name_and_kept() {
2251 let declared = project(vec![("shop", Resources::default())]);
2252 let mut lock = DeployLock::default();
2253 lock.record_r2("default", "shop", "R2_OLD", "shop-old", None);
2254 let order = names(&["shop"]);
2255 let plan = plan_of(&order, &declared, &lock);
2256 let report = plan_report(&plan, DeployFormat::Short);
2257 assert!(
2258 report.contains("orphan r2 shop R2_OLD (shop-old; not deleted)\n"),
2259 "{report}"
2260 );
2261 assert!(!plan.orphans.has_prunable());
2262 }
2263
2264 #[test]
2265 fn a_derived_name_r2_would_refuse_is_an_error_naming_the_instance() {
2266 let lock = DeployLock::default();
2267 let long = project(vec![(
2268 "a-very-long-context-name-that-goes-on-and-on",
2269 Resources::default().r2_buckets(&[(
2270 "R2_SOME_RATHER_LONG_CAPABILITY_NAME",
2271 "a-very-long-context-name-that-goes-on-and-on-some-rather-long-capability-name",
2272 None,
2273 )]),
2274 )]);
2275 let message = r2_bucket_problem(&long, &lock, "default").expect("too long");
2276 assert!(
2277 message.contains("`R2_SOME_RATHER_LONG_CAPABILITY_NAME`"),
2278 "{message}"
2279 );
2280 assert!(message.contains("3 to 63 characters"), "{message}");
2281 let edge = project(vec![(
2283 "shop",
2284 Resources::default().r2_buckets(&[("R2_X", &"a".repeat(60), None)]),
2285 )]);
2286 assert!(r2_bucket_problem(&edge, &lock, "default").is_none());
2287 assert!(r2_bucket_problem(&edge, &lock, "production").is_some());
2288 let underscore = project(vec![(
2289 "shop_front",
2290 Resources::default().r2_buckets(&[("R2_X", "shop_front-x", None)]),
2291 )]);
2292 assert!(
2293 r2_bucket_problem(&underscore, &lock, "default")
2294 .unwrap()
2295 .contains("lowercase letters, digits and hyphens")
2296 );
2297 }
2298
2299 #[test]
2300 fn two_instances_that_would_share_a_bucket_name_are_refused() {
2301 let colliding = project(vec![
2302 (
2303 "shop-cart",
2304 Resources::default().r2_buckets(&[("R2_A", "shop-cart-a", None)]),
2305 ),
2306 (
2307 "shop",
2308 Resources::default().r2_buckets(&[("R2_CART_A", "shop-cart-a", None)]),
2309 ),
2310 ]);
2311 let message =
2312 r2_bucket_problem(&colliding, &DeployLock::default(), "default").expect("a collision");
2313 assert!(message.contains("`shop-cart-a`"), "{message}");
2314 let mut lock = DeployLock::default();
2316 lock.record_r2(
2317 "production",
2318 "shop",
2319 "R2_OTHER",
2320 "shop-archives-staging",
2321 None,
2322 );
2323 let staging = project(vec![(
2324 "shop",
2325 Resources::default().r2_buckets(&[("R2_ARCHIVES", "shop-archives", None)]),
2326 )]);
2327 assert!(r2_bucket_problem(&staging, &lock, "staging").is_some());
2328 assert!(r2_bucket_problem(&staging, &lock, "default").is_none());
2329 }
2330
2331 #[test]
2334 fn a_foreign_service_is_a_plan_line_with_no_action() {
2335 let resources = project(vec![(
2336 "shop-billing",
2337 Resources::default().foreign(&[("SERVICE_BILLING", "legacy-billing")]),
2338 )]);
2339 let order = names(&["shop-billing"]);
2340 let plan = plan_of(&order, &resources, &DeployLock::default());
2341 assert_eq!(
2342 plan_report(&plan, DeployFormat::Short),
2343 "service SERVICE_BILLING legacy-billing (foreign Worker; not provisioned)\ndeploy shop-billing\n"
2347 );
2348 let json: serde_json::Value =
2349 serde_json::from_str(&plan_report(&plan, DeployFormat::Json)).unwrap();
2350 assert_eq!(
2351 json["contexts"][0]["foreign_services"],
2352 serde_json::json!([{ "binding": "SERVICE_BILLING", "worker": "legacy-billing" }])
2353 );
2354 assert_eq!(json["contexts"][0]["binds_to"], serde_json::json!([]));
2355 }
2356
2357 #[test]
2362 fn foreign_worker_warnings_ask_once_and_never_refuse() {
2363 let resources = project(vec![
2364 (
2365 "a",
2366 Resources::default().foreign(&[("SERVICE_BILLING", "legacy-billing")]),
2367 ),
2368 (
2369 "b",
2370 Resources::default().foreign(&[
2371 ("SERVICE_BILLING", "legacy-billing"),
2372 ("SERVICE_LEDGER", "ledger"),
2373 ("SERVICE_TAX", "tax"),
2374 ]),
2375 ),
2376 ]);
2377 let order = names(&["a", "b"]);
2378 let mut asked = Vec::new();
2379 let warnings = foreign_worker_warnings(&order, &resources, |name| {
2380 asked.push(name.to_string());
2381 match name {
2382 "legacy-billing" => WorkerPresence::Missing,
2383 "ledger" => WorkerPresence::Present,
2384 _ => WorkerPresence::Unknown("Authentication error [code: 10000]".into()),
2385 }
2386 });
2387 assert_eq!(asked, ["ledger", "legacy-billing", "tax"]);
2388 assert_eq!(warnings.len(), 2);
2389 assert!(
2390 warnings[0].contains("`a`, `b` binds to Worker `legacy-billing`")
2391 && warnings[0].contains("rejects a Service Binding to a missing Worker at upload"),
2392 "{}",
2393 warnings[0]
2394 );
2395 assert!(
2396 warnings[1].contains("could not check that Worker `tax`")
2397 && warnings[1].contains("[code: 10000]"),
2398 "{}",
2399 warnings[1]
2400 );
2401 }
2402
2403 #[test]
2405 fn classify_worker_listing_reads_only_not_found_as_missing() {
2406 assert_eq!(classify_worker_listing(true, ""), WorkerPresence::Present);
2407 assert_eq!(
2408 classify_worker_listing(
2409 false,
2410 "A request to the Cloudflare API (/accounts/x/workers/scripts/legacy-billing/deployments) failed.\n This Worker does not exist on your account. [code: 10007]"
2411 ),
2412 WorkerPresence::Missing
2413 );
2414 assert_eq!(
2415 classify_worker_listing(false, "… [code: 10090]"),
2416 WorkerPresence::Missing
2417 );
2418 assert!(matches!(
2419 classify_worker_listing(false, "fetch failed"),
2420 WorkerPresence::Unknown(_)
2421 ));
2422 }
2423
2424 fn lock_with(worker: &str, contracts: &[(&str, &str)]) -> DeployLock {
2427 let mut lock = DeployLock::default();
2428 lock.record_deployed(
2429 "default",
2430 worker,
2431 Some(
2432 contracts
2433 .iter()
2434 .map(|(s, h)| (s.to_string(), h.to_string()))
2435 .collect(),
2436 ),
2437 );
2438 lock
2439 }
2440
2441 fn expects(dep: &str, svc: &str, hash: &str) -> BTreeMap<String, BTreeMap<String, String>> {
2442 BTreeMap::from([(
2443 dep.to_string(),
2444 BTreeMap::from([(svc.to_string(), hash.to_string())]),
2445 )])
2446 }
2447
2448 #[test]
2449 fn conflicting_env_passthrough_finds_bare_and_equals_forms() {
2450 assert_eq!(
2451 conflicting_env_passthrough(&names(&["--env", "production"])),
2452 Some("--env")
2453 );
2454 assert_eq!(
2455 conflicting_env_passthrough(&names(&["--env=production"])),
2456 Some("--env=production")
2457 );
2458 assert_eq!(
2459 conflicting_env_passthrough(&names(&["--environment", "production"])),
2460 Some("--environment")
2461 );
2462 assert_eq!(
2463 conflicting_env_passthrough(&names(&["--minify"])),
2464 None,
2465 "an unrelated flag is not a conflict"
2466 );
2467 assert_eq!(conflicting_env_passthrough(&[]), None);
2468 }
2469
2470 #[test]
2471 fn plan_creates_or_reuses_kv_from_the_ledger() {
2472 let order = names(&["api"]);
2473 let declared = project(vec![("api", Resources::default().needs_kv())]);
2474 let fresh = DeployLock::default();
2475 assert_eq!(
2476 plan_of(&order, &declared, &fresh).contexts[0].kv[0].action,
2477 "create"
2478 );
2479 assert_eq!(
2480 plan_of(&order, &declared, &with_kv(DeployLock::default(), "api")).contexts[0].kv[0]
2481 .action,
2482 "reuse"
2483 );
2484 assert!(
2485 plan_of(
2486 &order,
2487 &project(vec![("api", Resources::default())]),
2488 &fresh
2489 )
2490 .contexts[0]
2491 .kv
2492 .is_empty(),
2493 "a context declaring no KV gets no KV line"
2494 );
2495 }
2496
2497 #[test]
2500 fn plan_creates_or_reuses_a_queue_by_its_name() {
2501 let order = names(&["jobs"]);
2504 let declared = project(vec![("jobs", Resources::default().consumes(&["intake"]))]);
2505 let line =
2506 |lock: &DeployLock| plan_of(&order, &declared, lock).contexts[0].queues[0].action;
2507 assert_eq!(line(&DeployLock::default()), "create");
2508 assert_eq!(line(&with_queue(DeployLock::default(), "intake")), "reuse");
2509 assert!(with_queue(DeployLock::default(), "intake").has_queue("default", "intake"));
2512 assert!(!with_queue(DeployLock::default(), "intake").has_queue("default", "other"));
2513 }
2514
2515 #[test]
2516 fn a_context_with_no_queues_gets_no_queue_lines() {
2517 assert!(
2518 plan_of(
2519 &names(&["api"]),
2520 &project(vec![("api", Resources::default())]),
2521 &DeployLock::default(),
2522 )
2523 .contexts[0]
2524 .queues
2525 .is_empty()
2526 );
2527 }
2528
2529 #[test]
2530 fn a_passthrough_secrets_file_is_refused_only_when_the_run_has_a_first_deploy() {
2531 let order = names(&["api", "web"]);
2533 let mut lock = DeployLock::default();
2534 lock.record_deployed("staging", "api", None);
2535 let args = |a: &[&str]| names(a);
2536 assert_eq!(
2537 secrets_file_passthrough_refusal(
2538 &args(&["--secrets-file", "s.env"]),
2539 &order,
2540 &lock,
2541 "staging"
2542 )
2543 .as_deref(),
2544 Some(
2545 "bynk: `--secrets-file` after `--` conflicts with the secrets file `bynk deploy` uploads with a new Worker's first version (`web-staging`).\n \
2546 Pass it to bynk instead, before `--`: `bynk deploy --secrets-file <file>`.\n"
2547 )
2548 );
2549 assert!(
2550 secrets_file_passthrough_refusal(
2551 &args(&["--secrets-file=s.env"]),
2552 &order,
2553 &lock,
2554 "staging"
2555 )
2556 .is_some_and(|r| r.contains("`--secrets-file=s.env`")),
2557 "the `=`-joined spelling is the same flag"
2558 );
2559 lock.record_deployed("staging", "web", None);
2560 assert_eq!(
2561 secrets_file_passthrough_refusal(
2562 &args(&["--secrets-file", "s.env"]),
2563 &order,
2564 &lock,
2565 "staging"
2566 ),
2567 None,
2568 "a redeploy passes no secrets file of its own"
2569 );
2570 assert_eq!(
2571 secrets_file_passthrough_refusal(&args(&["--minify"]), &order, &lock, "default"),
2572 None
2573 );
2574 }
2575
2576 #[test]
2577 fn a_wrangler_below_the_floor_refuses_only_a_push_that_declares_a_durable_object() {
2578 let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).unwrap();
2581 let wrangler = |version: probe::Version| probe::Probe {
2582 tool: "wrangler".to_string(),
2583 version: Some(version),
2584 provenance: probe::Provenance::Path("/usr/bin/wrangler".into()),
2585 };
2586 let old = if min.patch > 0 {
2588 probe::Version {
2589 patch: min.patch - 1,
2590 ..min
2591 }
2592 } else if min.minor > 0 {
2593 probe::Version {
2594 minor: min.minor - 1,
2595 patch: 999,
2596 ..min
2597 }
2598 } else {
2599 probe::Version {
2600 major: min.major - 1,
2601 minor: 999,
2602 patch: 999,
2603 }
2604 };
2605 let resources = project(vec![
2606 ("api", Resources::default()),
2607 ("jobs", Resources::default().exports(&["JobLedger"])),
2608 ]);
2609 let both = names(&["api", "jobs"]);
2610
2611 let refusal = wrangler_floor_refusal(&wrangler(old), &both, &resources)
2612 .expect("an agent-bearing push below the floor is refused");
2613 for part in [
2614 format!("wrangler {old}"),
2615 bynk_emit::WRANGLER_MIN.to_string(),
2616 "`jobs`".to_string(),
2617 "npm install -g wrangler@4".to_string(),
2618 ] {
2619 assert!(refusal.contains(&part), "missing {part:?} in: {refusal}");
2620 }
2621
2622 assert_eq!(
2625 wrangler_floor_refusal(&wrangler(old), &names(&["api"]), &resources),
2626 None
2627 );
2628 assert_eq!(
2629 wrangler_floor_refusal(&wrangler(min), &both, &resources),
2630 None
2631 );
2632 let npx = probe::Probe {
2633 tool: "wrangler".to_string(),
2634 version: None,
2635 provenance: probe::Provenance::Npx,
2636 };
2637 assert_eq!(wrangler_floor_refusal(&npx, &both, &resources), None);
2638 }
2639
2640 #[test]
2644 fn renames_and_deletions_are_planned_by_what_they_do_to_data() {
2645 let order = names(&["shop-orders"]);
2646 let declared = Resources {
2647 renamed_durable_objects: vec![("Cart".to_string(), "Basket".to_string())],
2648 deleted_durable_objects: vec!["Wishlist".to_string()],
2649 ..Resources::default().exports(&["Basket"])
2650 };
2651 let resources = project(vec![("shop-orders", declared)]);
2652 let plan = plan_of(&order, &resources, &DeployLock::default());
2653 let context = &plan.contexts[0];
2654 assert_eq!(context.deleted_durable_objects.len(), 1);
2655 assert_eq!(
2656 context.deleted_durable_objects[0].class,
2657 "shop.orders.Wishlist"
2658 );
2659 assert!(!context.deleted_durable_objects[0].already_applied);
2660 let text = plan_report(&plan, DeployFormat::Short);
2661 assert!(
2662 text.contains("rename durable object Cart → Basket (moves its stored data)"),
2663 "{text}"
2664 );
2665 assert!(
2666 text.contains(
2667 "delete durable object shop.orders.Wishlist (all stored data; irreversible)"
2668 ),
2669 "{text}"
2670 );
2671 let json = plan_report(&plan, DeployFormat::Json);
2672 assert!(json.contains("\"durable_objects_renamed\""), "{json}");
2673 assert!(json.contains("\"shop.orders.Wishlist\""), "{json}");
2674 }
2675
2676 #[test]
2679 fn a_push_records_its_tombstones_for_the_next_plan() {
2680 let order = names(&["shop"]);
2681 let declared = Resources {
2682 renamed_durable_objects: vec![("Box".to_string(), "Crate".to_string())],
2683 deleted_durable_objects: vec!["Wishlist".to_string()],
2684 ..Resources::default().exports(&["Crate"])
2685 };
2686 let mut lock = DeployLock::default();
2687 record_pushed_tombstones(&mut lock, "default", "shop", &declared);
2688 let resources = project(vec![("shop", declared)]);
2689 let plan = plan_of(&order, &resources, &lock);
2690 assert!(plan.contexts[0].renamed_durable_objects[0].already_applied);
2691 assert!(plan.contexts[0].deleted_durable_objects[0].already_applied);
2692 record_pushed_tombstones(&mut lock, "default", "shop", &Resources::default());
2693 assert!(!lock.has_renamed_class("default", "shop", "Box", "Crate"));
2694 assert!(!lock.has_deleted_class("default", "shop", "Wishlist"));
2695 }
2696
2697 #[test]
2700 fn a_rename_already_pushed_reads_as_applied() {
2701 let order = names(&["shop"]);
2702 let declared = Resources {
2703 renamed_durable_objects: vec![("Box".to_string(), "Crate".to_string())],
2704 ..Resources::default().exports(&["Crate"])
2705 };
2706 let resources = project(vec![("shop", declared)]);
2707 let mut lock = DeployLock::default();
2708 lock.record_renamed_classes(
2709 "default",
2710 "shop",
2711 [("Box".to_string(), "Crate".to_string())].into(),
2712 );
2713 let plan = plan_of(&order, &resources, &lock);
2714 assert!(plan.contexts[0].renamed_durable_objects[0].already_applied);
2715 let text = plan_report(&plan, DeployFormat::Short);
2716 assert!(
2717 text.contains("rename durable object Box → Crate (already applied by an earlier deploy; the tombstone stays until Cloudflare reports it stale)"),
2718 "{text}"
2719 );
2720 let json = plan_report(&plan, DeployFormat::Json);
2721 assert!(json.contains("\"already_applied\": true"), "{json}");
2722 lock.record_renamed_classes(
2723 "default",
2724 "shop",
2725 [("Box".to_string(), "Bin".to_string())].into(),
2726 );
2727 let plan = plan_of(&order, &resources, &lock);
2728 assert!(!plan.contexts[0].renamed_durable_objects[0].already_applied);
2729 lock.record_renamed_classes("default", "shop", BTreeMap::new());
2730 assert!(!lock.has_renamed_class("default", "shop", "Box", "Bin"));
2731 assert!(
2732 !toml::to_string(&lock).unwrap().contains("renamed_classes"),
2733 "an empty record leaves nothing in the ledger"
2734 );
2735 }
2736
2737 #[test]
2742 fn a_deletion_needs_its_own_named_confirmation() {
2743 let order = names(&["shop"]);
2744 let declared = Resources {
2745 deleted_durable_objects: vec!["Wishlist".to_string()],
2746 ..Resources::default()
2747 };
2748 let resources = project(vec![("shop", declared)]);
2749 let lock = DeployLock::default();
2750 let plan = plan_of(&order, &resources, &lock);
2751 assert!(
2752 !confirm_deletions(&plan, &[], &lock),
2753 "unconfirmed: refused"
2754 );
2755 assert!(confirm_deletions(&plan, &names(&["shop.Wishlist"]), &lock));
2756 assert!(
2757 !confirm_deletions(&plan, &names(&["shop.Wishlist", "shop.Other"]), &lock),
2758 "a confirmation for a class not deleted is refused"
2759 );
2760 let none = project(vec![("shop", Resources::default())]);
2761 let quiet = plan_of(&order, &none, &lock);
2762 assert!(
2763 confirm_deletions(&quiet, &[], &lock),
2764 "nothing to delete, nothing to confirm"
2765 );
2766 assert!(!confirm_deletions(
2767 &quiet,
2768 &names(&["shop.Wishlist"]),
2769 &lock
2770 ));
2771 }
2772
2773 #[test]
2779 fn a_deletion_already_pushed_is_not_confirmed_again() {
2780 let order = names(&["shop"]);
2781 let declared = Resources {
2782 deleted_durable_objects: vec!["Wishlist".to_string()],
2783 ..Resources::default()
2784 };
2785 let resources = project(vec![("shop", declared)]);
2786 let mut lock = DeployLock::default();
2787 lock.record_deleted_classes("default", "shop", ["Wishlist".to_string()].into());
2788 let plan = plan_of(&order, &resources, &lock);
2789 assert!(plan.contexts[0].deleted_durable_objects[0].already_applied);
2790 let text = plan_report(&plan, DeployFormat::Short);
2791 assert!(
2792 text.contains("already deleted by an earlier deploy"),
2793 "{text}"
2794 );
2795 assert!(
2796 confirm_deletions(&plan, &[], &lock),
2797 "an applied deletion asks nothing"
2798 );
2799 assert!(confirm_deletions(&plan, &names(&["shop.Wishlist"]), &lock));
2800 let gone = project(vec![("shop", Resources::default())]);
2803 let after = plan_of(&order, &gone, &lock);
2804 assert!(confirm_deletions(&after, &names(&["shop.Wishlist"]), &lock));
2805 assert!(!confirm_deletions(&after, &names(&["shop.Other"]), &lock));
2806 }
2807
2808 #[test]
2809 fn the_durable_object_lines_are_advisory_in_every_ledger_state() {
2810 let order = names(&["jobs"]);
2816 let declared = project(vec![(
2817 "jobs",
2818 Resources::default().exports(&["JobLedger", "__EventsFanout"]),
2819 )]);
2820 for lock in [DeployLock::default(), lock_with_deployed(&["jobs"])] {
2821 let plan = plan_of(&order, &declared, &lock);
2822 let objects = &plan.contexts[0].durable_objects;
2823 assert_eq!(
2824 objects.iter().map(|o| o.class).collect::<Vec<_>>(),
2825 ["JobLedger", "__EventsFanout"],
2826 "every declared class gets a line, the fan-out class included"
2827 );
2828 for object in objects {
2829 assert_eq!(object.storage, "sqlite");
2830 assert_eq!(
2831 object.reconciled_by, "Cloudflare",
2832 "the plan names an owner other than bynk — that is the advisory"
2833 );
2834 }
2835 }
2836 assert!(
2838 plan_of(
2839 &names(&["api"]),
2840 &project(vec![("api", Resources::default())]),
2841 &DeployLock::default(),
2842 )
2843 .contexts[0]
2844 .durable_objects
2845 .is_empty()
2846 );
2847 }
2848
2849 #[test]
2852 fn context_flag_names_a_dependency_that_was_never_deployed() {
2853 let g = graph(&[("orders", &["payment"]), ("payment", &[])]);
2854 assert_eq!(
2855 absent_dependencies("orders", &g, &DeployLock::default(), "default"),
2856 names(&["payment"]),
2857 "deploying orders alone would fail at upload — say which target is missing"
2858 );
2859 assert!(
2861 absent_dependencies("orders", &g, &lock_with_deployed(&["payment"]), "default")
2862 .is_empty()
2863 );
2864 assert!(absent_dependencies("payment", &g, &DeployLock::default(), "default").is_empty());
2866 }
2867
2868 #[test]
2869 fn the_plan_distinguishes_a_first_deploy_from_a_redeploy() {
2870 let order = names(&["api"]);
2871 let declared = project(vec![("api", Resources::default())]);
2872 assert_eq!(
2873 plan_of(&order, &declared, &DeployLock::default()).contexts[0].action,
2874 "deploy"
2875 );
2876 assert_eq!(
2877 plan_of(&order, &declared, &lock_with_deployed(&["api"])).contexts[0].action,
2878 "redeploy",
2879 "a re-run re-pushes rather than skipping, so the plan must not say `deploy`"
2880 );
2881 }
2882
2883 #[test]
2884 fn the_plan_carries_the_order_and_each_context_s_bindings() {
2885 let order = names(&["payment", "orders"]);
2886 let declared = project(vec![
2887 ("orders", Resources::default().binds(&["payment"])),
2888 ("payment", Resources::default()),
2889 ]);
2890 let plan = plan_of(&order, &declared, &DeployLock::default());
2891 assert_eq!(plan.order, vec!["payment", "orders"]);
2892 assert_eq!(plan.contexts[1].worker, "orders");
2893 assert_eq!(plan.contexts[1].binds_to, vec!["payment"]);
2894 assert!(plan.contexts[0].binds_to.is_empty());
2895 }
2896
2897 #[test]
2902 fn under_a_named_environment_the_plan_names_what_the_push_creates() {
2903 let order = names(&["payment", "orders"]);
2904 let declared = project(vec![
2905 (
2906 "orders",
2907 Resources::default().binds(&["payment"]).consumes(&["jobs"]),
2908 ),
2909 ("payment", Resources::default()),
2910 ]);
2911 let mut lock = DeployLock::default();
2912 lock.record_deployed("staging", "payment", Some(Default::default()));
2913 lock.record_deployed("staging", "gone", Some(Default::default()));
2914 lock.record_queue("staging", "jobs");
2915 lock.record_queue("staging", "old-jobs");
2916 let plan = derive_plan(
2917 &order,
2918 &declared,
2919 &lock,
2920 &SecretSource::default(),
2921 false,
2922 "staging",
2923 );
2924 assert_eq!(plan.order, vec!["payment-staging", "orders-staging"]);
2925 assert_eq!(plan.contexts[0].worker, "payment-staging");
2926 assert_eq!(plan.contexts[0].action, "redeploy", "found by logical name");
2927 assert_eq!(plan.contexts[1].worker, "orders-staging");
2928 assert_eq!(plan.contexts[1].action, "deploy");
2929 assert_eq!(plan.contexts[1].binds_to, vec!["payment-staging"]);
2930 assert_eq!(plan.contexts[1].queues[0].queue, "jobs-staging");
2931 assert_eq!(plan.contexts[1].queues[0].action, "reuse");
2932 assert_eq!(plan.shown_orphans.workers, vec!["gone-staging"]);
2934 assert_eq!(plan.shown_orphans.queues, vec!["old-jobs-staging"]);
2935 assert_eq!(plan.orphans.workers, vec!["gone"]);
2936 assert_eq!(plan.orphans.queues, vec!["old-jobs"]);
2937
2938 let text = plan_report(&plan, DeployFormat::Short);
2939 assert!(text.contains("orphan worker gone-staging\n"), "{text}");
2940 assert!(text.contains("orphan queue old-jobs-staging\n"), "{text}");
2941 assert!(text.contains("queue reuse jobs-staging\n"), "{text}");
2942 assert!(text.contains("redeploy payment-staging\n"), "{text}");
2943 assert!(text.contains("deploy orders-staging\n"), "{text}");
2944 assert!(
2945 text.contains("order payment-staging → orders-staging\n"),
2946 "{text}"
2947 );
2948
2949 let json: serde_json::Value =
2950 serde_json::from_str(&plan_report(&plan, DeployFormat::Json)).unwrap();
2951 assert_eq!(
2952 json["order"],
2953 serde_json::json!(["payment-staging", "orders-staging"])
2954 );
2955 assert_eq!(json["contexts"][1]["worker"], "orders-staging");
2956 assert_eq!(
2957 json["contexts"][1]["binds_to"],
2958 serde_json::json!(["payment-staging"])
2959 );
2960 assert_eq!(json["contexts"][1]["queues"][0]["queue"], "jobs-staging");
2961 assert_eq!(
2962 json["orphans"]["workers"],
2963 serde_json::json!(["gone-staging"])
2964 );
2965 assert_eq!(
2966 json["orphans"]["queues"],
2967 serde_json::json!(["old-jobs-staging"])
2968 );
2969 }
2970
2971 #[test]
2976 fn the_plan_and_the_push_share_one_name_function() {
2977 const ORDERS: &str = r#"name = "shop-orders"
2978main = "index.ts"
2979
2980[[services]]
2981binding = "PAYMENT"
2982service = "shop-payment"
2983
2984[[queues.consumers]]
2985queue = "jobs"
2986"#;
2987 let order = names(&["shop-payment", "shop-orders"]);
2988 let declared = project(vec![
2989 (
2990 "shop-orders",
2991 Resources::default()
2992 .binds(&["shop-payment"])
2993 .consumes(&["jobs"]),
2994 ),
2995 ("shop-payment", Resources::default()),
2996 ]);
2997 for environment in ["staging", "production", "default"] {
2998 let plan = derive_plan(
2999 &order,
3000 &declared,
3001 &DeployLock::default(),
3002 &SecretSource::default(),
3003 false,
3004 environment,
3005 );
3006 let orders = &plan.contexts[1];
3007 let pushed: toml::Table = synthesise_environment_block(
3008 ORDERS,
3009 environment,
3010 &BTreeMap::new(),
3011 &BTreeSet::new(),
3012 )
3013 .unwrap()
3014 .parse()
3015 .unwrap();
3016 let block = &pushed["env"][environment];
3017 assert_eq!(block["name"].as_str(), Some(orders.worker.as_str()));
3018 assert_eq!(
3019 block["services"][0]["service"].as_str(),
3020 Some(orders.binds_to[0].as_str())
3021 );
3022 assert_eq!(
3023 block["queues"]["consumers"][0]["queue"].as_str(),
3024 Some(orders.queues[0].queue.as_str())
3025 );
3026 assert_eq!(plan.order[1], orders.worker);
3027 assert_eq!(
3028 orders.worker,
3029 deployed_worker_name(environment, "shop-orders")
3030 );
3031 }
3032 }
3033
3034 #[test]
3035 fn dry_run_and_ci_gates_do_not_reach_mutation() {
3036 assert!(
3037 DeployOptions {
3038 dry_run: true,
3039 ..Default::default()
3040 }
3041 .dry_run
3042 );
3043 }
3044
3045 #[test]
3048 fn a_kv_title_two_namespaces_would_share_is_refused() {
3049 let colliding = project(vec![
3050 (
3051 "shop",
3052 Resources::default().kv_instances(&["KV_CART_SESSIONS"]),
3053 ),
3054 (
3055 "shop-cart",
3056 Resources::default().kv_instances(&["KV_SESSIONS"]),
3057 ),
3058 ]);
3059 let message =
3060 kv_title_collision(&colliding, &DeployLock::default(), "default").expect("a collision");
3061 assert!(
3062 message.contains("`shop-cart-sessions`")
3063 && message.contains("`KV_CART_SESSIONS` for `shop`")
3064 && message.contains("`KV_SESSIONS` for `shop-cart`"),
3065 "{message}"
3066 );
3067 let bare = project(vec![
3069 ("shop", Resources::default().kv_instances(&["KV_CART"])),
3070 ("shop-cart", Resources::default().needs_kv()),
3071 ]);
3072 assert!(kv_title_collision(&bare, &DeployLock::default(), "default").is_some());
3073 let distinct = project(vec![(
3074 "shop-cart",
3075 Resources::default()
3076 .needs_kv()
3077 .kv_instances(&["KV_PRICE_CACHE", "KV_SESSIONS"]),
3078 )]);
3079 assert_eq!(
3080 kv_title_collision(&distinct, &DeployLock::default(), "staging"),
3081 None
3082 );
3083 }
3084
3085 fn shop_with_staging_instance() -> BTreeMap<String, Resources> {
3089 project(vec![(
3090 "shop",
3091 Resources::default()
3092 .needs_kv()
3093 .kv_instances(&["KV_STAGING"]),
3094 )])
3095 }
3096
3097 #[test]
3101 fn a_title_another_environment_holds_is_refused() {
3102 let resources = shop_with_staging_instance();
3103 let mut lock = DeployLock::default();
3104 lock.record_kv("default", "shop", "KV", "bare-id");
3105 lock.record_kv("default", "shop", "KV_STAGING", "instance-id");
3106 let message = kv_title_collision(&resources, &lock, "staging").expect("a collision");
3107 assert_eq!(
3108 message,
3109 "KV namespace for `shop` in `--env staging` and KV namespace `KV_STAGING` for `shop` \
3110 in the default environment would both be titled `shop-staging` on the account, \
3111 which holds one namespace per title; rename a capability or context so they differ"
3112 );
3113 }
3114
3115 #[test]
3119 fn a_title_another_environment_would_create_is_refused() {
3120 let resources = shop_with_staging_instance();
3121 let mut lock = DeployLock::default();
3122 lock.record_deployed("staging", "other", Some(Default::default()));
3124 let message = kv_title_collision(&resources, &lock, "default").expect("a collision");
3125 assert!(
3126 message.contains("KV namespace `KV_STAGING` for `shop` in the default environment")
3127 && message.contains("KV namespace for `shop` in `--env staging`")
3128 && message.contains("`shop-staging`"),
3129 "{message}"
3130 );
3131 }
3132
3133 #[test]
3137 fn an_orphan_in_another_environment_still_holds_its_title() {
3138 let resources = project(vec![(
3139 "shop",
3140 Resources::default().kv_instances(&["KV_STAGING"]),
3141 )]);
3142 let mut lock = DeployLock::default();
3143 lock.record_kv("staging", "shop", "KV", "orphaned-id");
3144 let message = kv_title_collision(&resources, &lock, "default").expect("a collision");
3145 assert!(
3146 message.contains("KV namespace for `shop` in `--env staging`"),
3147 "{message}"
3148 );
3149 }
3150
3151 #[test]
3155 fn a_namespace_already_recorded_here_is_not_refused() {
3156 let resources = shop_with_staging_instance();
3157 let mut lock = DeployLock::default();
3158 lock.record_kv("default", "shop", "KV", "bare-id");
3159 lock.record_kv("default", "shop", "KV_STAGING", "instance-id");
3160 lock.record_deployed("staging", "other", Some(Default::default()));
3161 assert_eq!(kv_title_collision(&resources, &lock, "default"), None);
3162 assert!(kv_title_collision(&resources, &lock, "staging").is_some());
3164 }
3165
3166 #[test]
3169 fn unknown_environments_and_distinct_titles_pass() {
3170 let resources = shop_with_staging_instance();
3171 assert_eq!(
3172 kv_title_collision(&resources, &DeployLock::default(), "staging"),
3173 None
3174 );
3175 let plain = project(vec![(
3176 "shop-cart",
3177 Resources::default()
3178 .needs_kv()
3179 .kv_instances(&["KV_SESSIONS"]),
3180 )]);
3181 let mut lock = DeployLock::default();
3182 lock.record_kv("default", "shop-cart", "KV", "bare-id");
3183 lock.record_kv("default", "shop-cart", "KV_SESSIONS", "sessions-id");
3184 lock.record_kv("production", "shop-cart", "KV", "prod-id");
3185 assert_eq!(kv_title_collision(&plain, &lock, "staging"), None);
3186 }
3187
3188 #[test]
3191 fn ci_refuses_the_first_unrecorded_namespace_bare_or_instance() {
3192 use crate::deploy::ledger::tests::with_kv_instance;
3193 let order = names(&["shop-cart"]);
3194 let declared = project(vec![(
3195 "shop-cart",
3196 Resources::default()
3197 .needs_kv()
3198 .kv_instances(&["KV_PRICE_CACHE", "KV_SESSIONS"]),
3199 )]);
3200 assert_eq!(
3201 first_unrecorded_kv(&order, &declared, &DeployLock::default(), "default"),
3202 Some(("shop-cart", "KV"))
3203 );
3204 let bare = with_kv(DeployLock::default(), "shop-cart");
3205 assert_eq!(
3206 first_unrecorded_kv(&order, &declared, &bare, "default"),
3207 Some(("shop-cart", "KV_PRICE_CACHE"))
3208 );
3209 let all = with_kv_instance(
3210 with_kv_instance(bare, "shop-cart", "KV_PRICE_CACHE"),
3211 "shop-cart",
3212 "KV_SESSIONS",
3213 );
3214 assert_eq!(
3215 first_unrecorded_kv(&order, &declared, &all, "default"),
3216 None
3217 );
3218 assert_eq!(
3219 first_unrecorded_kv(&order, &declared, &all, "staging"),
3220 Some(("shop-cart", "KV")),
3221 "recorded under another environment is unrecorded here"
3222 );
3223 }
3224
3225 #[test]
3226 fn an_interrupted_rotation_says_how_to_repair_it() {
3227 let edge = |caller: &str, callee: &str| Edge {
3228 caller: caller.to_string(),
3229 callee: callee.to_string(),
3230 };
3231 let set = |rotating: bool| EdgeKey::Set {
3232 value: "minted-value".into(),
3233 rotating,
3234 };
3235 let keys: BTreeMap<Edge, EdgeKey> = [
3236 (edge("commerce-orders", "commerce-payment"), set(true)),
3237 (edge("commerce-orders", "commerce-ledger"), set(false)),
3238 (edge("commerce-ledger", "commerce-payment"), EdgeKey::Keep),
3239 ]
3240 .into_iter()
3241 .collect();
3242 let hint = interrupted_rotation_hint("default", &keys, &names(&["commerce-payment"]))
3244 .expect("a rotation was interrupted");
3245 assert!(
3246 hint.contains("`commerce-orders` → `commerce-payment`"),
3247 "{hint}"
3248 );
3249 assert!(
3250 !hint.contains("commerce-ledger"),
3251 "an edge that had no key, or kept its key, cannot be split: {hint}"
3252 );
3253 assert!(hint.contains("bynk deploy --force"), "{hint}");
3254 assert!(!hint.contains("minted-value"), "never the value: {hint}");
3255 assert_eq!(
3257 interrupted_rotation_hint(
3258 "default",
3259 &keys,
3260 &names(&["commerce-payment", "commerce-orders"])
3261 ),
3262 None
3263 );
3264 }
3265
3266 #[test]
3267 fn non_interactive_deploy_requires_yes() {
3268 assert!(!requires_interactive_confirmation(false, false));
3269 assert!(requires_interactive_confirmation(false, true));
3270 assert!(!requires_interactive_confirmation(true, false));
3271 }
3272
3273 #[test]
3274 fn matching_contracts_are_not_a_skew() {
3275 let lock = lock_with("app-b", &[("whoami", "317bdd3de84d2176")]);
3276 let found = contract_skews(
3277 &expects("app.b", "whoami", "317bdd3de84d2176"),
3278 &lock,
3279 |c| c.replace('.', "-"),
3280 "default",
3281 );
3282 assert!(found.is_empty(), "{found:?}");
3283 }
3284
3285 #[test]
3286 fn a_changed_contract_is_a_skew() {
3287 let lock = lock_with("app-b", &[("whoami", "ffffffffffffffff")]);
3290 let found = contract_skews(
3291 &expects("app.b", "whoami", "317bdd3de84d2176"),
3292 &lock,
3293 |c| c.replace('.', "-"),
3294 "default",
3295 );
3296 assert_eq!(found.len(), 1);
3297 assert_eq!(found[0].dependency, "app.b");
3298 assert_eq!(found[0].service, "whoami");
3299 assert_eq!(found[0].expected, "317bdd3de84d2176");
3300 assert_eq!(found[0].live, "ffffffffffffffff");
3301 }
3302
3303 #[test]
3304 fn a_service_the_live_callee_no_longer_provides_is_a_skew() {
3305 let lock = lock_with("app-b", &[("somethingElse", "317bdd3de84d2176")]);
3306 let found = contract_skews(
3307 &expects("app.b", "whoami", "317bdd3de84d2176"),
3308 &lock,
3309 |c| c.replace('.', "-"),
3310 "default",
3311 );
3312 assert_eq!(found.len(), 1);
3313 assert_eq!(found[0].live, "<absent>");
3314 }
3315
3316 #[test]
3317 fn a_ledger_with_no_contract_record_yields_no_finding() {
3318 let mut lock = DeployLock::default();
3324 lock.record_deployed("default", "app-b", None);
3325 let found = contract_skews(
3326 &expects("app.b", "whoami", "317bdd3de84d2176"),
3327 &lock,
3328 |c| c.replace('.', "-"),
3329 "default",
3330 );
3331 assert!(found.is_empty(), "{found:?}");
3332 }
3333
3334 #[test]
3335 fn a_callee_that_now_provides_nothing_is_a_total_skew() {
3336 let lock = lock_with("app-b", &[]);
3343 let found = contract_skews(
3344 &expects("app.b", "whoami", "317bdd3de84d2176"),
3345 &lock,
3346 |c| c.replace('.', "-"),
3347 "default",
3348 );
3349 assert_eq!(found.len(), 1);
3350 assert_eq!(found[0].live, "<absent>");
3351 }
3352
3353 #[test]
3354 fn a_never_deployed_dependency_yields_no_finding_here() {
3355 let found = contract_skews(
3358 &expects("app.b", "whoami", "317bdd3de84d2176"),
3359 &DeployLock::default(),
3360 |c| c.replace('.', "-"),
3361 "default",
3362 );
3363 assert!(found.is_empty(), "{found:?}");
3364 }
3365
3366 fn live_shop(orders_expects: Option<&str>) -> DeployLock {
3371 let mut lock = lock_with("shop-payment", &[("authorise", "febd15c2757b72e1")]);
3372 lock.record_expects("default", "shop-payment", BTreeMap::new());
3373 lock.record_deployed("default", "shop-orders", Some(BTreeMap::new()));
3374 if let Some(hash) = orders_expects {
3375 lock.record_expects(
3376 "default",
3377 "shop-orders",
3378 expects("shop.payment", "authorise", hash),
3379 );
3380 }
3381 lock
3382 }
3383
3384 fn provides(svc: &str, hash: &str) -> BTreeMap<String, String> {
3385 BTreeMap::from([(svc.to_string(), hash.to_string())])
3386 }
3387
3388 fn worker_of(c: &str) -> String {
3389 c.replace('.', "-")
3390 }
3391
3392 #[test]
3393 fn a_callee_whose_contract_changed_under_a_live_caller_is_refused() {
3394 let lock = live_shop(Some("febd15c2757b72e1"));
3398 let found = callee_contract_skews(
3399 "shop-payment",
3400 &provides("authorise", "da0d725f3a004040"),
3401 &lock,
3402 worker_of,
3403 "default",
3404 );
3405 assert_eq!(
3406 found,
3407 [CalleeSkew {
3408 caller: "shop-orders".into(),
3409 dependency: "shop.payment".into(),
3410 service: "authorise".into(),
3411 expected: "febd15c2757b72e1".into(),
3412 provided: "da0d725f3a004040".into(),
3413 }]
3414 );
3415 let refusal = callee_contract_skew_refusal("default", "shop-payment", &found);
3416 assert!(refusal.contains("`shop-orders` calls shop.payment.authorise"));
3417 assert!(refusal.contains(CONTRACT_SKEW));
3418 }
3419
3420 #[test]
3423 fn a_built_caller_is_refused_with_the_whole_project_remedy() {
3424 let lock = live_shop(Some("febd15c2757b72e1"));
3425 let found = callee_contract_skews(
3426 "shop-payment",
3427 &provides("authorise", "da0d725f3a004040"),
3428 &lock,
3429 worker_of,
3430 "default",
3431 );
3432 let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3433 let (built, orphaned) = split_orphaned_callers(found, &bindings);
3434 assert_eq!(built.len(), 1);
3435 assert!(orphaned.is_empty(), "{orphaned:?}");
3436 let refusal = callee_contract_skew_refusal("default", "shop-payment", &built);
3437 assert!(refusal.contains("Deploy the whole project (`bynk deploy`)"));
3438 }
3439
3440 #[test]
3445 fn an_orphaned_caller_is_refused_with_the_orphan_remedy() {
3446 let mut lock = live_shop(Some("febd15c2757b72e1"));
3447 lock.record_deployed("staging", "shop-payment", Some(BTreeMap::new()));
3448 lock.record_deployed("staging", "shop-legacy", Some(BTreeMap::new()));
3449 lock.record_expects(
3450 "staging",
3451 "shop-legacy",
3452 expects("shop.payment", "authorise", "febd15c2757b72e1"),
3453 );
3454 let found = callee_contract_skews(
3455 "shop-payment",
3456 &provides("authorise", "da0d725f3a004040"),
3457 &lock,
3458 worker_of,
3459 "staging",
3460 );
3461 let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3463 let (built, orphaned) = split_orphaned_callers(found, &bindings);
3464 assert!(built.is_empty(), "{built:?}");
3465 assert_eq!(orphaned.len(), 1);
3466 assert_eq!(orphaned[0].caller, "shop-legacy");
3467 let refusal = orphaned_caller_skew_refusal("staging", "shop-payment", &orphaned);
3468 assert!(refusal.contains(CONTRACT_SKEW), "{refusal}");
3469 assert!(
3470 refusal.contains("`shop-legacy-staging` (orphaned) calls shop.payment.authorise"),
3471 "{refusal}"
3472 );
3473 assert!(
3474 refusal.contains("`npx wrangler delete --name shop-legacy-staging`")
3475 && refusal.contains("`[environments.staging.workers.shop-legacy]`")
3476 && refusal.contains("`bynk deploy --env staging`"),
3477 "{refusal}"
3478 );
3479 }
3480
3481 #[test]
3482 fn a_callee_that_still_provides_the_live_hash_passes() {
3483 let lock = live_shop(Some("febd15c2757b72e1"));
3484 let found = callee_contract_skews(
3485 "shop-payment",
3486 &provides("authorise", "febd15c2757b72e1"),
3487 &lock,
3488 worker_of,
3489 "default",
3490 );
3491 assert!(found.is_empty(), "{found:?}");
3492 }
3493
3494 #[test]
3495 fn a_service_a_live_caller_still_calls_but_the_callee_removed_is_refused() {
3496 let lock = live_shop(Some("febd15c2757b72e1"));
3497 let found = callee_contract_skews(
3498 "shop-payment",
3499 &BTreeMap::new(),
3500 &lock,
3501 worker_of,
3502 "default",
3503 );
3504 assert_eq!(found.len(), 1);
3505 assert_eq!(found[0].provided, "<absent>");
3506 }
3507
3508 #[test]
3509 fn a_pushed_worker_is_never_held_to_its_own_stale_record() {
3510 let mut lock = live_shop(Some("febd15c2757b72e1"));
3517 lock.record_expects(
3518 "default",
3519 "shop-orders",
3520 expects("shop.orders", "place", "0000000000000000"),
3521 );
3522 let found =
3523 callee_contract_skews("shop-orders", &BTreeMap::new(), &lock, worker_of, "default");
3524 assert!(found.is_empty(), "{found:?}");
3525 }
3526
3527 #[test]
3528 fn a_caller_recorded_before_expects_is_not_refused_but_is_named_for_a_warning() {
3529 let lock = live_shop(None);
3533 let found = callee_contract_skews(
3534 "shop-payment",
3535 &provides("authorise", "da0d725f3a004040"),
3536 &lock,
3537 worker_of,
3538 "default",
3539 );
3540 assert!(found.is_empty(), "{found:?}");
3541 let bindings = graph(&[("shop-orders", &["shop-payment"]), ("shop-payment", &[])]);
3542 assert_eq!(
3543 unrecorded_callers("shop-payment", &bindings, &lock, "default"),
3544 names(&["shop-orders"])
3545 );
3546 let unrelated = graph(&[("shop-orders", &[]), ("shop-payment", &[])]);
3548 assert!(unrecorded_callers("shop-payment", &unrelated, &lock, "default").is_empty());
3549 let recorded = live_shop(Some("febd15c2757b72e1"));
3551 assert!(unrecorded_callers("shop-payment", &bindings, &recorded, "default").is_empty());
3552 }
3553
3554 #[test]
3555 fn a_caller_that_expects_nothing_of_the_callee_is_not_a_finding() {
3556 let mut lock = live_shop(None);
3557 lock.record_expects("default", "shop-orders", BTreeMap::new());
3558 let found = callee_contract_skews(
3559 "shop-payment",
3560 &BTreeMap::new(),
3561 &lock,
3562 worker_of,
3563 "default",
3564 );
3565 assert!(found.is_empty(), "{found:?}");
3566 }
3567
3568 #[test]
3569 fn a_caller_live_in_another_environment_is_not_this_environments_caller() {
3570 let lock = live_shop(Some("febd15c2757b72e1"));
3571 let found = callee_contract_skews(
3572 "shop-payment",
3573 &provides("authorise", "da0d725f3a004040"),
3574 &lock,
3575 worker_of,
3576 "staging",
3577 );
3578 assert!(found.is_empty(), "{found:?}");
3579 }
3580}