1use std::collections::{BTreeMap, BTreeSet};
26use std::io::{self, IsTerminal, Write};
27use std::path::Path;
28use std::process::{ExitCode, Stdio};
29
30use serde::{Deserialize, Serialize};
31
32use crate::compiler::Compiler;
33use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
34use crate::probe::{self, DetectOpts, Provenance, Toolbox};
35use crate::report::{self, Format};
36use crate::shell::exit_status_byte;
37use crate::workers;
38
39const LOCK_FILE: &str = "bynk.deploy.lock";
40#[cfg(test)]
46use bynk_emit::emitter::wrangler::KV_NAMESPACE_ID_PLACEHOLDER;
47
48mod config;
49mod graph;
50mod ledger;
51mod pins;
52mod plan;
53mod provisioning;
54mod secrets;
55
56use config::*;
57use graph::*;
58use ledger::*;
59use pins::*;
60use plan::*;
61use provisioning::*;
62use secrets::*;
63
64pub use ledger::materialise_deploy_state;
67pub(crate) use plan::conflicting_env_passthrough;
68pub use plan::{DeployFormat, DeployOptions, run};
69pub(crate) use config::deployed_worker_name;
72pub(crate) use secrets::mint_edge_key as mint_key;
73
74pub(crate) fn deployed_environments(
78 project_root: &Path,
79 workers: &[String],
80) -> Result<BTreeMap<String, Vec<String>>, String> {
81 let lock = read_lock(&project_root.join(LOCK_FILE))?;
82 Ok(workers
83 .iter()
84 .map(|w| (w.clone(), lock.deployed_environments(w)))
85 .collect())
86}
87
88pub(crate) fn local_edge_keys(
93 workers_dir: &Path,
94 workers: &[String],
95) -> Result<BTreeMap<String, Vec<String>>, String> {
96 let resources = project_resources(workers_dir, workers)?;
97 let edges = project_edges(&resources);
98 for edge in &edges {
99 edge.ends()?;
100 }
101 Ok(workers
104 .iter()
105 .map(|w| {
106 let mut keys = edge_keys_of(w, &edges);
107 keys.extend(
108 resources[w]
109 .foreign_callers
110 .iter()
111 .map(|f| f.binding.clone()),
112 );
113 (w.clone(), keys)
114 })
115 .filter(|(_, keys)| !keys.is_empty())
116 .collect())
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use config::tests::project;
123 use ledger::tests::{lock_with_deployed, with_kv, with_kv_instance, with_queue};
124 use plan::tests::plan_of;
125 use secrets::tests::source;
126
127 fn names(v: &[&str]) -> Vec<String> {
128 v.iter().map(|s| s.to_string()).collect()
129 }
130
131 fn chain() -> BTreeMap<String, Resources> {
134 project(vec![
135 (
136 "commerce-orders",
137 Resources::default().binds(&["commerce-payment"]),
138 ),
139 ("commerce-payment", Resources::default().needs_kv()),
140 ])
141 }
142
143 fn bless_or_assert(name: &str, actual: &str) {
149 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
150 .join("tests/golden")
151 .join(name);
152 if std::env::var_os("BYNK_BLESS").is_some() {
153 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
154 std::fs::write(&path, actual).unwrap();
155 return;
156 }
157 let expected = std::fs::read_to_string(&path).unwrap_or_else(|_| {
158 panic!(
159 "missing golden {}; regenerate with BYNK_BLESS=1 cargo test -p bynk",
160 path.display()
161 )
162 });
163 assert_eq!(
164 actual, expected,
165 "golden {name} drifted; re-bless with BYNK_BLESS=1 cargo test -p bynk"
166 );
167 }
168
169 #[test]
175 fn golden_deploy_plan() {
176 let chain_order = names(&["commerce-payment", "commerce-orders"]);
177
178 let mut out = String::new();
179
180 out.push_str("# one context, first deploy\n");
183 out.push_str(&plan_report(
184 &plan_of(
185 &names(&["api"]),
186 &project(vec![("api", Resources::default().needs_kv())]),
187 &DeployLock::default(),
188 ),
189 DeployFormat::Short,
190 ));
191
192 out.push_str("\n# several contexts, first deploy\n");
194 out.push_str(&plan_report(
195 &plan_of(&chain_order, &chain(), &DeployLock::default()),
196 DeployFormat::Short,
197 ));
198
199 out.push_str("\n# I3: foreign callers' secrets\n");
202 let mut foreign = chain();
203 foreign
204 .get_mut("commerce-payment")
205 .expect("the chain's callee")
206 .foreign_callers = vec![
207 ForeignCaller {
208 actor: "Ledger".into(),
209 binding: "LEDGER_CALL_KEY".into(),
210 worker: Some("ledger-svc".into()),
211 },
212 ForeignCaller {
213 actor: "Legacy".into(),
214 binding: "LEGACY_CALL_KEY".into(),
215 worker: None,
216 },
217 ];
218 out.push_str(&plan_report(
219 &plan_of(&chain_order, &foreign, &DeployLock::default()),
220 DeployFormat::Short,
221 ));
222
223 out.push_str("\n# several contexts, already live — a re-run re-pushes\n");
228 out.push_str(&plan_report(
229 &plan_of(
230 &chain_order,
231 &chain(),
232 &with_kv(
233 lock_with_deployed(&["commerce-payment", "commerce-orders"]),
234 "commerce-payment",
235 ),
236 ),
237 DeployFormat::Short,
238 ));
239
240 out.push_str("\n# slice 1: an agent and a queue, first deploy\n");
244 out.push_str(&plan_report(
245 &plan_of(
246 &names(&["jobs"]),
247 &project(vec![(
248 "jobs",
249 Resources::default()
250 .needs_kv()
251 .consumes(&["job-intake"])
252 .exports(&["JobLedger"]),
253 )]),
254 &DeployLock::default(),
255 ),
256 DeployFormat::Short,
257 ));
258
259 out.push_str("\n# slice 1: the same context, already provisioned\n");
260 out.push_str(&plan_report(
261 &plan_of(
262 &names(&["jobs"]),
263 &project(vec![(
264 "jobs",
265 Resources::default()
266 .needs_kv()
267 .consumes(&["job-intake"])
268 .exports(&["JobLedger"]),
269 )]),
270 &with_queue(with_kv(lock_with_deployed(&["jobs"]), "jobs"), "job-intake"),
271 ),
272 DeployFormat::Short,
273 ));
274
275 out.push_str("\n# slice 3: a declared auth secret, and one the user supplied\n");
279 out.push_str(&plan_report(
280 &derive_plan(
281 &names(&["api"]),
282 &project(vec![(
283 "api",
284 Resources::default().declares(&["AUTH_JWT_SECRET"]),
285 )]),
286 &DeployLock::default(),
287 &source(&[("STRIPE_KEY", "sk_live_x")], &[]),
288 false,
289 "default",
290 ),
291 DeployFormat::Short,
292 ));
293
294 out.push_str("\n# slice 3: --force overwrites rather than setting if absent\n");
298 out.push_str(&plan_report(
299 &derive_plan(
300 &names(&["api"]),
301 &project(vec![(
302 "api",
303 Resources::default().declares(&["AUTH_JWT_SECRET"]),
304 )]),
305 &lock_with_deployed(&["api"]),
306 &source(&[], &["PROBE_TOKEN"]),
307 true,
308 "default",
309 ),
310 DeployFormat::Short,
311 ));
312
313 out.push_str("\n# slice 3: a supplied secret reaches every context\n");
317 out.push_str(&plan_report(
318 &derive_plan(
319 &chain_order,
320 &chain(),
321 &DeployLock::default(),
322 &source(&[("SHARED_KEY", "v")], &[]),
323 false,
324 "default",
325 ),
326 DeployFormat::Short,
327 ));
328
329 out.push_str("\n# all three classes: declared (required), read (advisory), supplied\n");
334 out.push_str(&plan_report(
335 &derive_plan(
336 &names(&["api"]),
337 &project(vec![(
338 "api",
339 Resources::default()
340 .declares(&["AUTH_JWT_SECRET"])
341 .reads(&["STRIPE_KEY"]),
342 )]),
343 &DeployLock::default(),
344 &source(&[], &["PROBE_TOKEN"]),
345 false,
346 "default",
347 ),
348 DeployFormat::Short,
349 ));
350
351 out.push_str("\n# --format json\n");
352 out.push_str(&plan_report(
353 &plan_of(&chain_order, &chain(), &DeployLock::default()),
354 DeployFormat::Json,
355 ));
356
357 out.push_str("\n# --format json, a context that computes a secret name\n");
360 out.push_str(&plan_report(
361 &derive_plan(
362 &names(&["api"]),
363 &project(vec![(
364 "api",
365 Resources::default()
366 .reads(&["WELL_KNOWN"])
367 .reads_incompletely(),
368 )]),
369 &DeployLock::default(),
370 &SecretSource::default(),
371 false,
372 "default",
373 ),
374 DeployFormat::Json,
375 ));
376
377 out.push_str("\n# --format json, with declared and supplied secrets\n");
380 out.push_str(&plan_report(
381 &derive_plan(
382 &names(&["api"]),
383 &project(vec![(
384 "api",
385 Resources::default().declares(&["AUTH_JWT_SECRET", "WH_SECRET"]),
386 )]),
387 &DeployLock::default(),
388 &source(&[("STRIPE_KEY", "sk_live_x")], &["PROBE_TOKEN"]),
389 false,
390 "default",
391 ),
392 DeployFormat::Json,
393 ));
394
395 out.push_str("\n# --format json, with a queue and a durable object\n");
398 out.push_str(&plan_report(
399 &plan_of(
400 &names(&["jobs"]),
401 &project(vec![(
402 "jobs",
403 Resources::default()
404 .consumes(&["job-intake"])
405 .exports(&["JobLedger"]),
406 )]),
407 &DeployLock::default(),
408 ),
409 DeployFormat::Json,
410 ));
411
412 let cart = || {
415 project(vec![(
416 "shop-cart",
417 Resources::default()
418 .needs_kv()
419 .kv_instances(&["KV_PRICE_CACHE", "KV_SESSIONS"]),
420 )])
421 };
422 out.push_str("\n# S4b: the bare namespace and two derived instances, first deploy\n");
423 out.push_str(&plan_report(
424 &plan_of(&names(&["shop-cart"]), &cart(), &DeployLock::default()),
425 DeployFormat::Short,
426 ));
427
428 out.push_str("\n# S4b: an instance renamed, under --env staging\n");
434 let mut staged = DeployLock::default();
435 staged.record_deployed("staging", "shop-cart", Some(Default::default()));
436 staged.record_kv("staging", "shop-cart", "KV", "bare-id");
437 staged.record_kv("staging", "shop-cart", "KV_SESSIONS", "sessions-id");
438 staged.record_kv("staging", "shop-cart", "KV_CARTS", "carts-id");
439 let cart_order = names(&["shop-cart"]);
440 let cart_resources = cart();
441 let renamed = derive_plan(
442 &cart_order,
443 &cart_resources,
444 &staged,
445 &SecretSource::default(),
446 false,
447 "staging",
448 );
449 out.push_str(&plan_report(&renamed, DeployFormat::Short));
450
451 out.push_str("\n# --format json, the same rename\n");
452 out.push_str(&plan_report(&renamed, DeployFormat::Json));
453
454 out.push_str("\n# --env staging: several contexts and a queue, first deploy\n");
458 let staging_resources = project(vec![
459 (
460 "commerce-orders",
461 Resources::default()
462 .binds(&["commerce-payment"])
463 .consumes(&["job-intake"]),
464 ),
465 ("commerce-payment", Resources::default().needs_kv()),
466 ]);
467 let staging = derive_plan(
468 &chain_order,
469 &staging_resources,
470 &DeployLock::default(),
471 &SecretSource::default(),
472 false,
473 "staging",
474 );
475 out.push_str(&plan_report(&staging, DeployFormat::Short));
476 out.push_str("\n# --format json, the same plan under --env staging\n");
477 out.push_str(&plan_report(&staging, DeployFormat::Json));
478
479 let bare = project(vec![("api", Resources::default().needs_kv())]);
483 let bare_order = names(&["api"]);
484 for environment in ["default", "staging"] {
485 out.push_str(&format!(
486 "\n# #2037: the bare namespace, first deploy, --env {environment}\n"
487 ));
488 out.push_str(&plan_report(
489 &derive_plan(
490 &bare_order,
491 &bare,
492 &DeployLock::default(),
493 &SecretSource::default(),
494 false,
495 environment,
496 ),
497 DeployFormat::Short,
498 ));
499 }
500
501 out.push_str("\n# S4b: a removed context's bare namespace and instance, both orphans\n");
502 out.push_str(&plan_report(
503 &plan_of(
504 &names(&["api"]),
505 &project(vec![("api", Resources::default())]),
506 &with_kv_instance(
507 with_kv(DeployLock::default(), "gone"),
508 "gone",
509 "KV_SESSIONS",
510 ),
511 ),
512 DeployFormat::Short,
513 ));
514
515 out.push_str("\n# interop I5: a foreign Worker beside a project context\n");
518 let foreign = project(vec![
519 (
520 "shop-checkout",
521 Resources::default()
522 .binds(&["shop-payment"])
523 .foreign(&[("SERVICE_BILLING", "legacy-billing")]),
524 ),
525 ("shop-payment", Resources::default()),
526 ]);
527 let order = names(&["shop-payment", "shop-checkout"]);
528 out.push_str(&plan_report(
529 &plan_of(&order, &foreign, &DeployLock::default()),
530 DeployFormat::Short,
531 ));
532
533 bless_or_assert("deploy-plan.txt", &out);
534 }
535
536 #[test]
540 fn golden_deploy_stopped() {
541 let mut out = String::new();
542 out.push_str("# the last context failed: nothing was left to withhold\n");
543 out.push_str(&stopped_report("default", &[]));
544 out.push_str("# one context was left\n");
545 out.push_str(&stopped_report("default", &names(&["commerce-orders"])));
546 out.push_str("# several were left\n");
547 out.push_str(&stopped_report(
548 "default",
549 &names(&["commerce-orders", "commerce-shipping"]),
550 ));
551 out.push_str("# --env staging: one context was left\n");
554 out.push_str(&stopped_report("staging", &names(&["commerce-orders"])));
555 out.push_str("# --env staging: several were left\n");
556 out.push_str(&stopped_report(
557 "staging",
558 &names(&["commerce-orders", "commerce-shipping"]),
559 ));
560 bless_or_assert("deploy-stopped.txt", &out);
561 }
562
563 #[test]
566 fn golden_deploy_context_refusal() {
567 let mut out = String::new();
568 out.push_str("# one target never deployed\n");
569 out.push_str(&absent_dependencies_refusal(
570 "default",
571 "commerce-orders",
572 &names(&["commerce-payment"]),
573 ));
574 out.push_str("# two targets never deployed\n");
575 out.push_str(&absent_dependencies_refusal(
576 "default",
577 "commerce-orders",
578 &names(&["commerce-payment", "commerce-shipping"]),
579 ));
580 out.push_str("# --env staging: one target never deployed\n");
581 out.push_str(&absent_dependencies_refusal(
582 "staging",
583 "commerce-orders",
584 &names(&["commerce-payment"]),
585 ));
586 out.push_str("# --env staging: two targets never deployed\n");
587 out.push_str(&absent_dependencies_refusal(
588 "staging",
589 "commerce-orders",
590 &names(&["commerce-payment", "commerce-shipping"]),
591 ));
592 bless_or_assert("deploy-context-refusal.txt", &out);
593 }
594
595 #[test]
599 fn golden_deploy_callee_contract_skew() {
600 fn skew(caller: &str, service: &str, expected: &str, provided: &str) -> CalleeSkew {
601 CalleeSkew {
602 caller: caller.into(),
603 dependency: "shop.payment".into(),
604 service: service.into(),
605 expected: expected.into(),
606 provided: provided.into(),
607 }
608 }
609 let one = [skew(
610 "shop-orders",
611 "authorise",
612 "febd15c2757b72e1",
613 "da0d725f3a004040",
614 )];
615 let several = [
616 skew(
617 "shop-orders",
618 "authorise",
619 "febd15c2757b72e1",
620 "da0d725f3a004040",
621 ),
622 skew("shop-refunds", "refund", "317bdd3de84d2176", "<absent>"),
623 ];
624 let mut out = String::new();
625 out.push_str("# one live caller on the old hash\n");
626 out.push_str(&callee_contract_skew_refusal(
627 "default",
628 "shop-payment",
629 &one,
630 ));
631 out.push_str("# two live callers, one service removed\n");
632 out.push_str(&callee_contract_skew_refusal(
633 "default",
634 "shop-payment",
635 &several,
636 ));
637 out.push_str("# --env staging: one live caller on the old hash\n");
638 out.push_str(&callee_contract_skew_refusal(
639 "staging",
640 "shop-payment",
641 &one,
642 ));
643 let orphaned = [skew(
644 "shop-legacy",
645 "authorise",
646 "febd15c2757b72e1",
647 "da0d725f3a004040",
648 )];
649 out.push_str("# a live caller of a context removed from the project\n");
650 out.push_str(&orphaned_caller_skew_refusal(
651 "default",
652 "shop-payment",
653 &orphaned,
654 ));
655 out.push_str("# --env staging: a live caller of a context removed from the project\n");
656 out.push_str(&orphaned_caller_skew_refusal(
657 "staging",
658 "shop-payment",
659 &orphaned,
660 ));
661 out.push_str("# a live caller recorded before expectations were\n");
662 out.push_str(&unrecorded_caller_warning(
663 "default",
664 "shop-payment",
665 "shop-orders",
666 ));
667 out.push_str("# --env staging: a live caller recorded before expectations were\n");
668 out.push_str(&unrecorded_caller_warning(
669 "staging",
670 "shop-payment",
671 "shop-orders",
672 ));
673 bless_or_assert("deploy-callee-contract-skew.txt", &out);
674 }
675
676 #[test]
681 fn golden_deploy_env_messages() {
682 let edge = Edge {
683 caller: "commerce-orders".into(),
684 callee: "commerce-payment".into(),
685 };
686 let skews = vec![ContractSkew {
687 dependency: "commerce.payment".into(),
688 service: "authorise".into(),
689 expected: "aaaa".into(),
690 live: "bbbb".into(),
691 }];
692 let caller = ForeignCaller {
693 actor: "Ledger".into(),
694 binding: "LEDGER_CALL_KEY".into(),
695 worker: Some("ledger-svc".into()),
696 };
697 let rotating: BTreeMap<Edge, EdgeKey> = [(
698 edge.clone(),
699 EdgeKey::Set {
700 value: "minted-value".into(),
701 rotating: true,
702 },
703 )]
704 .into_iter()
705 .collect();
706 let mut out = String::new();
707 for environment in ["default", "staging"] {
708 let section = |out: &mut String, title: &str| {
709 out.push_str(&format!("# {title}, environment {environment}\n"));
710 };
711 section(&mut out, "contract skew refusal");
712 out.push_str(&contract_skew_refusal(
713 environment,
714 "commerce-orders",
715 &skews,
716 ));
717 section(&mut out, "edge key presence unknown");
718 out.push_str(&unknown_edge_key_notice(
719 environment,
720 "commerce-payment",
721 "BYNK_CALL_FROM_COMMERCE__ORDERS",
722 &edge,
723 ));
724 section(&mut out, "edge needs a key a --context run cannot copy");
725 out.push_str(
726 &decide_edge_key(
727 &edge,
728 Presence::Present,
729 Presence::Absent,
730 false,
731 false,
732 &mut || panic!("a refused edge mints nothing"),
733 environment,
734 )
735 .expect_err("refused"),
736 );
737 out.push('\n');
738 section(&mut out, "interrupted rotation");
739 out.push_str(
740 &interrupted_rotation_hint(environment, &rotating, &names(&["commerce-payment"]))
741 .expect("a rotation was interrupted"),
742 );
743 section(&mut out, "progress");
744 out.push_str(&deploying_notice(environment, "commerce-orders", 2, 2));
745 section(&mut out, "deployed but not recorded");
746 out.push_str(&unrecorded_deploy_failure(
747 environment,
748 "commerce-orders",
749 "bynk.deploy.lock",
750 "permission denied",
751 ));
752 section(&mut out, "foreign caller's secret may be set");
753 out.push_str(&foreign_binding_maybe_set_notice(
754 environment,
755 "commerce-orders",
756 &caller,
757 ));
758 section(&mut out, "foreign caller's binding refused");
759 out.push_str(&foreign_binding_refusal(
760 environment,
761 "commerce-orders",
762 &caller,
763 "the supplied value is empty — every call from it would be refused",
764 ));
765 section(&mut out, "foreign caller hand-off");
766 out.push_str(&foreign_hand_off(
767 environment,
768 "commerce-orders",
769 &caller,
770 "abc123",
771 ));
772 section(&mut out, "unseen secret reads");
773 out.push_str(&unseen_reads_notice(environment, "commerce-orders"));
774 section(&mut out, "secret already set");
775 out.push_str(&kept_binding_notice(
776 environment,
777 "commerce-orders",
778 "API_KEY",
779 ));
780 section(&mut out, "optional read unsupplied");
781 out.push_str(&unsupplied_read_notice(
782 environment,
783 "commerce-orders",
784 "API_KEY",
785 ));
786 section(&mut out, "secret presence unknown");
787 out.push_str(&unknown_presence_notice(
788 environment,
789 "commerce-orders",
790 "AUTH_SECRET",
791 ));
792 section(&mut out, "secret put failed");
793 out.push_str(&binding_put_failure(
794 environment,
795 "commerce-orders",
796 "API_KEY",
797 "exit 1",
798 ));
799 out.push('\n');
800 section(&mut out, "declared secret missing");
801 out.push_str(&missing_binding_message(
802 environment,
803 "AUTH_SECRET",
804 Origin::Declared,
805 "commerce-orders",
806 ));
807 out.push('\n');
808 }
809 bless_or_assert("deploy-env-messages.txt", &out);
813 }
814
815 #[test]
816 fn the_stop_report_counts_only_what_is_left_and_agrees_with_its_list() {
817 for environment in ["default", "staging"] {
821 assert_eq!(
822 stopped_report(environment, &[]),
823 "",
824 "the failure itself is already reported"
825 );
826 }
827 for (environment, n) in ["default", "staging"]
828 .into_iter()
829 .flat_map(|environment| (1..5usize).map(move |n| (environment, n)))
830 {
831 let rest = names(&["c0", "c1", "c2", "c3"][..n]);
832 let report = stopped_report(environment, &rest);
833 let listed = report
834 .split(" not deployed: ")
835 .nth(1)
836 .and_then(|tail| tail.split(". Re-run").next())
837 .expect("the list sits between the count and the remedy");
838 assert_eq!(
839 listed.split(", ").count(),
840 n,
841 "the list names every withheld context: {report}"
842 );
843 let count = if n == 1 {
844 "1 more context was".to_string()
845 } else {
846 format!("{n} further contexts were")
847 };
848 assert!(
849 report.contains(&count),
850 "the count states the number it lists: {report}"
851 );
852 }
853 }
854}