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