Skip to main content

bynk/
deploy.rs

1//! `bynk deploy` — provision persistent Cloudflare identity, then publish.
2//!
3//! The generated `wrangler.toml` is deliberately disposable. This module owns
4//! the small, committed `bynk.deploy.lock` ledger and materialises its KV ids
5//! into a freshly compiled worker immediately before Wrangler sees it.
6//!
7//! The command mints real Cloudflare resources and writes secrets, so it is
8//! split by concern rather than kept as one file — following the layout
9//! `bynk-emit/src/project.rs` established: this parent carries the shared
10//! imports, the `mod` declarations, and the re-exports external callers see,
11//! while each child opens with `use super::*;` and documents its own items.
12//!
13//! - `config.rs` — the generated `wrangler.toml` / build-output model, and the
14//!   `[env.<name>]` synthesis a non-default `--env` needs.
15//! - `graph.rs` — the binding graph, the upload order it forces, and the
16//!   deploy-time contract-skew check.
17//! - `ledger.rs` — the committed `bynk.deploy.lock`: what this project has
18//!   provisioned, the orphan diff against it, and `--prune`.
19//! - `pins.rs` — a declared foreign caller's pin (`bynk.foreign.toml`) and the
20//!   gate that refuses a build breaking it (I4, #1970).
21//! - `provisioning.rs` — every call out to the `wrangler` CLI.
22//! - `secrets.rs` — which secrets a run sets, and where each value comes from.
23//! - `plan.rs` — the plan/apply flow that drives all of the above.
24
25use 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// P7.4 (#1305): every non-test use of the placeholder now goes through
41// `bynk_emit::emitter::wrangler`'s own structural functions
42// (`materialise_kv_namespace_ids`/`kv_bindings_needing_ids`) —
43// this name survives only for `deploy/ledger.rs`'s own test fixtures, which
44// build a minimal `wrangler.toml` containing it.
45#[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
64// External facade: the paths `main.rs` and `dev.rs` already use must keep
65// resolving exactly as they did before the split.
66pub use ledger::materialise_deploy_state;
67pub(crate) use plan::conflicting_env_passthrough;
68pub use plan::{DeployFormat, DeployOptions, run};
69// State-migrations S4 (#2259): `bynk migrate sweep` names deployed Workers as
70// `deploy` does, and mints its run's key as an edge key is minted.
71pub(crate) use config::deployed_worker_name;
72pub(crate) use secrets::mint_edge_key as mint_key;
73
74/// State-migrations S4 (#2259): for each Worker directory in `workers`, the
75/// environments `bynk.deploy.lock` at `project_root` records it as pushed in.
76/// A missing ledger records none.
77pub(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
88/// I2 (#1971): for `bynk dev`, the names of the edge keys each built Worker
89/// holds, and (I3) its foreign callers' secrets (a Worker with none is absent), read from the build's own
90/// configurations as `bynk deploy` reads them. An edge between contexts whose
91/// names no key can be derived from is an error, as it is at deploy.
92pub(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    // I3 (#1971): and the secret of each foreign caller a Worker admits, so a
102    // local foreign caller signing under the local key is admitted too.
103    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    /// The guide's worked example: `commerce-orders` binds to
132    /// `commerce-payment`, which is the one with the KV namespace.
133    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    /// The goldens live beside the integration ones (`tests/golden/`) and bless
144    /// identically — `BYNK_BLESS=1 cargo test -p bynk`. They are driven from
145    /// here rather than from `tests/` because `derive_plan` reads the ledger and
146    /// the binding graph, which are this module's private types: goldening the
147    /// output must not force them into the crate's public API.
148    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    /// #601/#600: the plan is what `--dry-run` shows and the deploy guide
170    /// quotes, so it is pinned exactly — the `order` line (slice 2's
171    /// load-bearing claim), the queue and Durable Object lines (slice 1's, the
172    /// latter reshaped by #1796), and the
173    /// JSON shape, which is a documented machine-readable surface.
174    #[test]
175    fn golden_deploy_plan() {
176        let chain_order = names(&["commerce-payment", "commerce-orders"]);
177
178        let mut out = String::new();
179
180        // Slice 0's shape: one context, nothing recorded. No `order` line —
181        // there is no ordering claim to make about a single worker.
182        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        // The guide's worked example: payment first, because orders binds to it.
193        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        // I3 (#1971): a context admitting two foreign callers lists each
200        // one's secret as managed, beside its edge keys.
201        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        // A re-run re-pushes rather than skipping, so the word is `redeploy`
224        // and the namespace is reused. The ledger records the KV *before* the
225        // push (ADR 0180), so a deployed context always has its namespace
226        // recorded too — depict that state, not an unreachable one.
227        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        // Slice 1's kinds. The Durable Object line is advisory in both states, so it
241        // reads the same before and after — that sameness is the point, and the
242        // golden is where it is visible.
243        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        // Slice 3. The origin mark is the load-bearing part: `declared` is the
276        // compiler's word, `supplied` is the user's, and a reader must not take
277        // the absence of a `declared` line for "this context needs no secret".
278        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        // `--force`: the action is `overwrite` rather than `set`. Presence is
295        // absent from the plan by design — it is a live question, and the plan
296        // is derived before auth so `--dry-run` stays offline.
297        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        // A supplied name goes to *every* context in the run: nothing says which
314        // contexts read a `bynk.Secrets` name. The plan lists it per context so
315        // that spread is visible rather than implied.
316        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        // The three classes side by side — the increment's whole surface. A
330        // reader must be able to tell the compiler's *required* knowledge
331        // (`declared`) from its *advisory* knowledge (`read`) from the user's
332        // word (`supplied`), because they fail differently.
333        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        // A computed name: the list is not a census, and the JSON is where a CI
358        // job learns that rather than trusting a short list.
359        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        // The JSON shape of slice 3's kinds — the surface a CI job reads to
378        // learn which names it must supply, and which the compiler already knows.
379        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        // The JSON shape of slice 1's kinds — the surface a CI job reads to
396        // learn that the Durable Object namespaces are not ours to claim.
397        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        // S4b (#1967): derived KV instances. Each is its own namespace, titled
413        // `<worker>-<capability>`; the bare `KV` keeps its line as it was.
414        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        // A renamed capability (`Carts` → `PriceCache`) is a new binding: a
429        // create beside the reuse, and the old instance an orphan, never
430        // pruned without `--prune`'s confirmation. Under `--env staging` every
431        // title is qualified, the bare one's too (#2037); the recorded ids are
432        // reused, whatever their namespaces were titled when made.
433        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        // #2028: under `--env staging` every Worker, binding target and queue
455        // is named as the push creates it, `-staging`, and so is the bare
456        // `KV`'s namespace title (#2037).
457        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        // #2037: the bare `KV`'s title, first deploy, default and staging. An
480        // account holds one namespace per title, so staging's is qualified
481        // and the default's is the title it has always had.
482        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        // Interop I5 (#1972): Service Bindings to Workers outside the
516        // project, bound and never provisioned or ordered against.
517        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    /// #601 D4: a failure stops the run and names what did not land. The count
537    /// and the list must agree, and the context that just failed — already
538    /// reported on its own line — must not be listed again here.
539    #[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        // #2038: under `--env staging` each context is named as the plan's
552        // `order` line names it, `-staging`.
553        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    /// D4 / #2038: `--context` refuses a Worker whose binding target has never
564    /// been deployed, naming both by their deployed name in the environment.
565    #[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    /// #2077: `--context` refuses a callee whose new build no longer provides
596    /// what its live callers were compiled against, naming each caller, the
597    /// contract and both hashes; and warns of a caller it cannot check.
598    #[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    /// #2047: every other deploy message that names a Worker or suggests a
677    /// command, under the default environment and `--env staging`. A Worker
678    /// is named by its deployed name, and a suggested `bynk deploy` repeats
679    /// the run's `--env`.
680    #[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        // Platform-independent by construction: the failed-push message needs
810        // a Unix `ExitStatus`, so it is pinned by
811        // `a_failed_push_names_the_deployed_worker` in provisioning.rs instead.
812        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        // The regression: the slice reported `order[i..]`, which included the
818        // context that had just failed — so a 3-context run failing at the 2nd
819        // said "1 more context was not deployed: b, c", naming two.
820        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}