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 id
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//! - `provisioning.rs` — every call out to the `wrangler` CLI.
20//! - `secrets.rs` — which secrets a run sets, and where each value comes from.
21//! - `plan.rs` — the plan/apply flow that drives all of the above.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::io::{self, IsTerminal, Write};
25use std::path::Path;
26use std::process::{ExitCode, Stdio};
27
28use serde::{Deserialize, Serialize};
29
30use crate::compiler::Compiler;
31use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
32use crate::probe::{self, DetectOpts, Provenance, Toolbox};
33use crate::report::{self, Format};
34use crate::shell::exit_status_byte;
35use crate::workers;
36
37const LOCK_FILE: &str = "bynk.deploy.lock";
38// P7.4 (#1305): every non-test use of the placeholder now goes through
39// `bynk_emit::emitter::wrangler`'s own structural functions
40// (`materialise_kv_namespace_id`/`wrangler_needs_kv_materialisation`) —
41// this name survives only for `deploy/ledger.rs`'s own test fixtures, which
42// build a minimal `wrangler.toml` containing it.
43#[cfg(test)]
44use bynk_emit::emitter::wrangler::KV_NAMESPACE_ID_PLACEHOLDER;
45
46mod config;
47mod graph;
48mod ledger;
49mod plan;
50mod provisioning;
51mod secrets;
52
53use config::*;
54use graph::*;
55use ledger::*;
56use plan::*;
57use provisioning::*;
58use secrets::*;
59
60// External facade: the paths `main.rs` and `dev.rs` already use must keep
61// resolving exactly as they did before the split.
62pub use ledger::materialise_deploy_state;
63pub(crate) use plan::conflicting_env_passthrough;
64pub use plan::{DeployFormat, DeployOptions, run};
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use config::tests::project;
70    use ledger::tests::{lock_with_deployed, with_kv, with_queue};
71    use plan::tests::plan_of;
72    use secrets::tests::source;
73
74    fn names(v: &[&str]) -> Vec<String> {
75        v.iter().map(|s| s.to_string()).collect()
76    }
77
78    /// The guide's worked example: `commerce-orders` binds to
79    /// `commerce-payment`, which is the one with the KV namespace.
80    fn chain() -> BTreeMap<String, Resources> {
81        project(vec![
82            (
83                "commerce-orders",
84                Resources::default().binds(&["commerce-payment"]),
85            ),
86            ("commerce-payment", Resources::default().needs_kv()),
87        ])
88    }
89
90    /// The goldens live beside the integration ones (`tests/golden/`) and bless
91    /// identically — `BYNK_BLESS=1 cargo test -p bynk`. They are driven from
92    /// here rather than from `tests/` because `derive_plan` reads the ledger and
93    /// the binding graph, which are this module's private types: goldening the
94    /// output must not force them into the crate's public API.
95    fn bless_or_assert(name: &str, actual: &str) {
96        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
97            .join("tests/golden")
98            .join(name);
99        if std::env::var_os("BYNK_BLESS").is_some() {
100            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
101            std::fs::write(&path, actual).unwrap();
102            return;
103        }
104        let expected = std::fs::read_to_string(&path).unwrap_or_else(|_| {
105            panic!(
106                "missing golden {}; regenerate with BYNK_BLESS=1 cargo test -p bynk",
107                path.display()
108            )
109        });
110        assert_eq!(
111            actual, expected,
112            "golden {name} drifted; re-bless with BYNK_BLESS=1 cargo test -p bynk"
113        );
114    }
115
116    /// #601/#600: the plan is what `--dry-run` shows and the deploy guide
117    /// quotes, so it is pinned exactly — the `order` line (slice 2's
118    /// load-bearing claim), the queue and Durable Object lines (slice 1's, the
119    /// latter reshaped by #1796), and the
120    /// JSON shape, which is a documented machine-readable surface.
121    #[test]
122    fn golden_deploy_plan() {
123        let chain_order = names(&["commerce-payment", "commerce-orders"]);
124
125        let mut out = String::new();
126
127        // Slice 0's shape: one context, nothing recorded. No `order` line —
128        // there is no ordering claim to make about a single worker.
129        out.push_str("# one context, first deploy\n");
130        out.push_str(&plan_report(
131            &plan_of(
132                &names(&["api"]),
133                &project(vec![("api", Resources::default().needs_kv())]),
134                &DeployLock::default(),
135            ),
136            DeployFormat::Short,
137        ));
138
139        // The guide's worked example: payment first, because orders binds to it.
140        out.push_str("\n# several contexts, first deploy\n");
141        out.push_str(&plan_report(
142            &plan_of(&chain_order, &chain(), &DeployLock::default()),
143            DeployFormat::Short,
144        ));
145
146        // A re-run re-pushes rather than skipping, so the word is `redeploy`
147        // and the namespace is reused. The ledger records the KV *before* the
148        // push (ADR 0180), so a deployed context always has its namespace
149        // recorded too — depict that state, not an unreachable one.
150        out.push_str("\n# several contexts, already live — a re-run re-pushes\n");
151        out.push_str(&plan_report(
152            &plan_of(
153                &chain_order,
154                &chain(),
155                &with_kv(
156                    lock_with_deployed(&["commerce-payment", "commerce-orders"]),
157                    "commerce-payment",
158                ),
159            ),
160            DeployFormat::Short,
161        ));
162
163        // Slice 1's kinds. The Durable Object line is advisory in both states, so it
164        // reads the same before and after — that sameness is the point, and the
165        // golden is where it is visible.
166        out.push_str("\n# slice 1: an agent and a queue, first deploy\n");
167        out.push_str(&plan_report(
168            &plan_of(
169                &names(&["jobs"]),
170                &project(vec![(
171                    "jobs",
172                    Resources::default()
173                        .needs_kv()
174                        .consumes(&["job-intake"])
175                        .exports(&["JobLedger"]),
176                )]),
177                &DeployLock::default(),
178            ),
179            DeployFormat::Short,
180        ));
181
182        out.push_str("\n# slice 1: the same context, already provisioned\n");
183        out.push_str(&plan_report(
184            &plan_of(
185                &names(&["jobs"]),
186                &project(vec![(
187                    "jobs",
188                    Resources::default()
189                        .needs_kv()
190                        .consumes(&["job-intake"])
191                        .exports(&["JobLedger"]),
192                )]),
193                &with_queue(with_kv(lock_with_deployed(&["jobs"]), "jobs"), "job-intake"),
194            ),
195            DeployFormat::Short,
196        ));
197
198        // Slice 3. The origin mark is the load-bearing part: `declared` is the
199        // compiler's word, `supplied` is the user's, and a reader must not take
200        // the absence of a `declared` line for "this context needs no secret".
201        out.push_str("\n# slice 3: a declared auth secret, and one the user supplied\n");
202        out.push_str(&plan_report(
203            &derive_plan(
204                &names(&["api"]),
205                &project(vec![(
206                    "api",
207                    Resources::default().declares(&["AUTH_JWT_SECRET"]),
208                )]),
209                &DeployLock::default(),
210                &source(&[("STRIPE_KEY", "sk_live_x")], &[]),
211                false,
212                "default",
213            ),
214            DeployFormat::Short,
215        ));
216
217        // `--force`: the action is `overwrite` rather than `set`. Presence is
218        // absent from the plan by design — it is a live question, and the plan
219        // is derived before auth so `--dry-run` stays offline.
220        out.push_str("\n# slice 3: --force overwrites rather than setting if absent\n");
221        out.push_str(&plan_report(
222            &derive_plan(
223                &names(&["api"]),
224                &project(vec![(
225                    "api",
226                    Resources::default().declares(&["AUTH_JWT_SECRET"]),
227                )]),
228                &lock_with_deployed(&["api"]),
229                &source(&[], &["PROBE_TOKEN"]),
230                true,
231                "default",
232            ),
233            DeployFormat::Short,
234        ));
235
236        // A supplied name goes to *every* context in the run: nothing says which
237        // contexts read a `bynk.Secrets` name. The plan lists it per context so
238        // that spread is visible rather than implied.
239        out.push_str("\n# slice 3: a supplied secret reaches every context\n");
240        out.push_str(&plan_report(
241            &derive_plan(
242                &chain_order,
243                &chain(),
244                &DeployLock::default(),
245                &source(&[("SHARED_KEY", "v")], &[]),
246                false,
247                "default",
248            ),
249            DeployFormat::Short,
250        ));
251
252        // The three classes side by side — the increment's whole surface. A
253        // reader must be able to tell the compiler's *required* knowledge
254        // (`declared`) from its *advisory* knowledge (`read`) from the user's
255        // word (`supplied`), because they fail differently.
256        out.push_str("\n# all three classes: declared (required), read (advisory), supplied\n");
257        out.push_str(&plan_report(
258            &derive_plan(
259                &names(&["api"]),
260                &project(vec![(
261                    "api",
262                    Resources::default()
263                        .declares(&["AUTH_JWT_SECRET"])
264                        .reads(&["STRIPE_KEY"]),
265                )]),
266                &DeployLock::default(),
267                &source(&[], &["PROBE_TOKEN"]),
268                false,
269                "default",
270            ),
271            DeployFormat::Short,
272        ));
273
274        out.push_str("\n# --format json\n");
275        out.push_str(&plan_report(
276            &plan_of(&chain_order, &chain(), &DeployLock::default()),
277            DeployFormat::Json,
278        ));
279
280        // A computed name: the list is not a census, and the JSON is where a CI
281        // job learns that rather than trusting a short list.
282        out.push_str("\n# --format json, a context that computes a secret name\n");
283        out.push_str(&plan_report(
284            &derive_plan(
285                &names(&["api"]),
286                &project(vec![(
287                    "api",
288                    Resources::default()
289                        .reads(&["WELL_KNOWN"])
290                        .reads_incompletely(),
291                )]),
292                &DeployLock::default(),
293                &SecretSource::default(),
294                false,
295                "default",
296            ),
297            DeployFormat::Json,
298        ));
299
300        // The JSON shape of slice 3's kinds — the surface a CI job reads to
301        // learn which names it must supply, and which the compiler already knows.
302        out.push_str("\n# --format json, with declared and supplied secrets\n");
303        out.push_str(&plan_report(
304            &derive_plan(
305                &names(&["api"]),
306                &project(vec![(
307                    "api",
308                    Resources::default().declares(&["AUTH_JWT_SECRET", "WH_SECRET"]),
309                )]),
310                &DeployLock::default(),
311                &source(&[("STRIPE_KEY", "sk_live_x")], &["PROBE_TOKEN"]),
312                false,
313                "default",
314            ),
315            DeployFormat::Json,
316        ));
317
318        // The JSON shape of slice 1's kinds — the surface a CI job reads to
319        // learn that the Durable Object namespaces are not ours to claim.
320        out.push_str("\n# --format json, with a queue and a durable object\n");
321        out.push_str(&plan_report(
322            &plan_of(
323                &names(&["jobs"]),
324                &project(vec![(
325                    "jobs",
326                    Resources::default()
327                        .consumes(&["job-intake"])
328                        .exports(&["JobLedger"]),
329                )]),
330                &DeployLock::default(),
331            ),
332            DeployFormat::Json,
333        ));
334
335        bless_or_assert("deploy-plan.txt", &out);
336    }
337
338    /// #601 D4: a failure stops the run and names what did not land. The count
339    /// and the list must agree, and the context that just failed — already
340    /// reported on its own line — must not be listed again here.
341    #[test]
342    fn golden_deploy_stopped() {
343        let mut out = String::new();
344        out.push_str("# the last context failed: nothing was left to withhold\n");
345        out.push_str(&stopped_report(&[]));
346        out.push_str("# one context was left\n");
347        out.push_str(&stopped_report(&names(&["commerce-orders"])));
348        out.push_str("# several were left\n");
349        out.push_str(&stopped_report(&names(&[
350            "commerce-orders",
351            "commerce-shipping",
352        ])));
353        bless_or_assert("deploy-stopped.txt", &out);
354    }
355
356    #[test]
357    fn the_stop_report_counts_only_what_is_left_and_agrees_with_its_list() {
358        // The regression: the slice reported `order[i..]`, which included the
359        // context that had just failed — so a 3-context run failing at the 2nd
360        // said "1 more context was not deployed: b, c", naming two.
361        assert_eq!(
362            stopped_report(&[]),
363            "",
364            "the failure itself is already reported"
365        );
366        for n in 1..5usize {
367            let rest = names(&["c0", "c1", "c2", "c3"][..n]);
368            let report = stopped_report(&rest);
369            let listed = report
370                .split(" not deployed: ")
371                .nth(1)
372                .and_then(|tail| tail.split(". Re-run").next())
373                .expect("the list sits between the count and the remedy");
374            assert_eq!(
375                listed.split(", ").count(),
376                n,
377                "the list names every withheld context: {report}"
378            );
379            let count = if n == 1 {
380                "1 more context was".to_string()
381            } else {
382                format!("{n} further contexts were")
383            };
384            assert!(
385                report.contains(&count),
386                "the count states the number it lists: {report}"
387            );
388        }
389    }
390}