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}