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 migration lines (slice 1's), and the
119    /// JSON shape, which is a documented machine-readable surface.
120    #[test]
121    fn golden_deploy_plan() {
122        let chain_order = names(&["commerce-payment", "commerce-orders"]);
123
124        let mut out = String::new();
125
126        // Slice 0's shape: one context, nothing recorded. No `order` line —
127        // there is no ordering claim to make about a single worker.
128        out.push_str("# one context, first deploy\n");
129        out.push_str(&plan_report(
130            &plan_of(
131                &names(&["api"]),
132                &project(vec![("api", Resources::default().needs_kv())]),
133                &DeployLock::default(),
134            ),
135            DeployFormat::Short,
136        ));
137
138        // The guide's worked example: payment first, because orders binds to it.
139        out.push_str("\n# several contexts, first deploy\n");
140        out.push_str(&plan_report(
141            &plan_of(&chain_order, &chain(), &DeployLock::default()),
142            DeployFormat::Short,
143        ));
144
145        // A re-run re-pushes rather than skipping, so the word is `redeploy`
146        // and the namespace is reused. The ledger records the KV *before* the
147        // push (ADR 0180), so a deployed context always has its namespace
148        // recorded too — depict that state, not an unreachable one.
149        out.push_str("\n# several contexts, already live — a re-run re-pushes\n");
150        out.push_str(&plan_report(
151            &plan_of(
152                &chain_order,
153                &chain(),
154                &with_kv(
155                    lock_with_deployed(&["commerce-payment", "commerce-orders"]),
156                    "commerce-payment",
157                ),
158            ),
159            DeployFormat::Short,
160        ));
161
162        // Slice 1's kinds. The migration line is advisory in both states, so it
163        // reads the same before and after — that sameness is the point, and the
164        // golden is where it is visible.
165        out.push_str("\n# slice 1: an agent and a queue, first deploy\n");
166        out.push_str(&plan_report(
167            &plan_of(
168                &names(&["jobs"]),
169                &project(vec![(
170                    "jobs",
171                    Resources::default()
172                        .needs_kv()
173                        .consumes(&["job-intake"])
174                        .migrates("v1"),
175                )]),
176                &DeployLock::default(),
177            ),
178            DeployFormat::Short,
179        ));
180
181        out.push_str("\n# slice 1: the same context, already provisioned\n");
182        out.push_str(&plan_report(
183            &plan_of(
184                &names(&["jobs"]),
185                &project(vec![(
186                    "jobs",
187                    Resources::default()
188                        .needs_kv()
189                        .consumes(&["job-intake"])
190                        .migrates("v1"),
191                )]),
192                &with_queue(with_kv(lock_with_deployed(&["jobs"]), "jobs"), "job-intake"),
193            ),
194            DeployFormat::Short,
195        ));
196
197        // Slice 3. The origin mark is the load-bearing part: `declared` is the
198        // compiler's word, `supplied` is the user's, and a reader must not take
199        // the absence of a `declared` line for "this context needs no secret".
200        out.push_str("\n# slice 3: a declared auth secret, and one the user supplied\n");
201        out.push_str(&plan_report(
202            &derive_plan(
203                &names(&["api"]),
204                &project(vec![(
205                    "api",
206                    Resources::default().declares(&["AUTH_JWT_SECRET"]),
207                )]),
208                &DeployLock::default(),
209                &source(&[("STRIPE_KEY", "sk_live_x")], &[]),
210                false,
211                "default",
212            ),
213            DeployFormat::Short,
214        ));
215
216        // `--force`: the action is `overwrite` rather than `set`. Presence is
217        // absent from the plan by design — it is a live question, and the plan
218        // is derived before auth so `--dry-run` stays offline.
219        out.push_str("\n# slice 3: --force overwrites rather than setting if absent\n");
220        out.push_str(&plan_report(
221            &derive_plan(
222                &names(&["api"]),
223                &project(vec![(
224                    "api",
225                    Resources::default().declares(&["AUTH_JWT_SECRET"]),
226                )]),
227                &lock_with_deployed(&["api"]),
228                &source(&[], &["PROBE_TOKEN"]),
229                true,
230                "default",
231            ),
232            DeployFormat::Short,
233        ));
234
235        // A supplied name goes to *every* context in the run: nothing says which
236        // contexts read a `bynk.Secrets` name. The plan lists it per context so
237        // that spread is visible rather than implied.
238        out.push_str("\n# slice 3: a supplied secret reaches every context\n");
239        out.push_str(&plan_report(
240            &derive_plan(
241                &chain_order,
242                &chain(),
243                &DeployLock::default(),
244                &source(&[("SHARED_KEY", "v")], &[]),
245                false,
246                "default",
247            ),
248            DeployFormat::Short,
249        ));
250
251        // The three classes side by side — the increment's whole surface. A
252        // reader must be able to tell the compiler's *required* knowledge
253        // (`declared`) from its *advisory* knowledge (`read`) from the user's
254        // word (`supplied`), because they fail differently.
255        out.push_str("\n# all three classes: declared (required), read (advisory), supplied\n");
256        out.push_str(&plan_report(
257            &derive_plan(
258                &names(&["api"]),
259                &project(vec![(
260                    "api",
261                    Resources::default()
262                        .declares(&["AUTH_JWT_SECRET"])
263                        .reads(&["STRIPE_KEY"]),
264                )]),
265                &DeployLock::default(),
266                &source(&[], &["PROBE_TOKEN"]),
267                false,
268                "default",
269            ),
270            DeployFormat::Short,
271        ));
272
273        out.push_str("\n# --format json\n");
274        out.push_str(&plan_report(
275            &plan_of(&chain_order, &chain(), &DeployLock::default()),
276            DeployFormat::Json,
277        ));
278
279        // A computed name: the list is not a census, and the JSON is where a CI
280        // job learns that rather than trusting a short list.
281        out.push_str("\n# --format json, a context that computes a secret name\n");
282        out.push_str(&plan_report(
283            &derive_plan(
284                &names(&["api"]),
285                &project(vec![(
286                    "api",
287                    Resources::default()
288                        .reads(&["WELL_KNOWN"])
289                        .reads_incompletely(),
290                )]),
291                &DeployLock::default(),
292                &SecretSource::default(),
293                false,
294                "default",
295            ),
296            DeployFormat::Json,
297        ));
298
299        // The JSON shape of slice 3's kinds — the surface a CI job reads to
300        // learn which names it must supply, and which the compiler already knows.
301        out.push_str("\n# --format json, with declared and supplied secrets\n");
302        out.push_str(&plan_report(
303            &derive_plan(
304                &names(&["api"]),
305                &project(vec![(
306                    "api",
307                    Resources::default().declares(&["AUTH_JWT_SECRET", "WH_SECRET"]),
308                )]),
309                &DeployLock::default(),
310                &source(&[("STRIPE_KEY", "sk_live_x")], &["PROBE_TOKEN"]),
311                false,
312                "default",
313            ),
314            DeployFormat::Json,
315        ));
316
317        // The JSON shape of slice 1's kinds — the surface a CI job reads to
318        // learn that the migration is not ours to claim.
319        out.push_str("\n# --format json, with a queue and a migration\n");
320        out.push_str(&plan_report(
321            &plan_of(
322                &names(&["jobs"]),
323                &project(vec![(
324                    "jobs",
325                    Resources::default()
326                        .consumes(&["job-intake"])
327                        .migrates("v1"),
328                )]),
329                &DeployLock::default(),
330            ),
331            DeployFormat::Json,
332        ));
333
334        bless_or_assert("deploy-plan.txt", &out);
335    }
336
337    /// #601 D4: a failure stops the run and names what did not land. The count
338    /// and the list must agree, and the context that just failed — already
339    /// reported on its own line — must not be listed again here.
340    #[test]
341    fn golden_deploy_stopped() {
342        let mut out = String::new();
343        out.push_str("# the last context failed: nothing was left to withhold\n");
344        out.push_str(&stopped_report(&[]));
345        out.push_str("# one context was left\n");
346        out.push_str(&stopped_report(&names(&["commerce-orders"])));
347        out.push_str("# several were left\n");
348        out.push_str(&stopped_report(&names(&[
349            "commerce-orders",
350            "commerce-shipping",
351        ])));
352        bless_or_assert("deploy-stopped.txt", &out);
353    }
354
355    #[test]
356    fn the_stop_report_counts_only_what_is_left_and_agrees_with_its_list() {
357        // The regression: the slice reported `order[i..]`, which included the
358        // context that had just failed — so a 3-context run failing at the 2nd
359        // said "1 more context was not deployed: b, c", naming two.
360        assert_eq!(
361            stopped_report(&[]),
362            "",
363            "the failure itself is already reported"
364        );
365        for n in 1..5usize {
366            let rest = names(&["c0", "c1", "c2", "c3"][..n]);
367            let report = stopped_report(&rest);
368            let listed = report
369                .split(" not deployed: ")
370                .nth(1)
371                .and_then(|tail| tail.split(". Re-run").next())
372                .expect("the list sits between the count and the remedy");
373            assert_eq!(
374                listed.split(", ").count(),
375                n,
376                "the list names every withheld context: {report}"
377            );
378            let count = if n == 1 {
379                "1 more context was".to_string()
380            } else {
381                format!("{n} further contexts were")
382            };
383            assert!(
384                report.contains(&count),
385                "the count states the number it lists: {report}"
386            );
387        }
388    }
389}