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}