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}