bynk/deploy/plan.rs
1use super::*;
2
3/// #1662: the diagnostic code a contract skew is reported under — a literal so
4/// the registry test sees it, and `bynk explain` knows it.
5const CONTRACT_SKEW: &str = "bynk.deploy.contract_skew";
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub enum DeployFormat {
9 #[default]
10 Short,
11 Json,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct DeployOptions {
16 pub dry_run: bool,
17 pub format: DeployFormat,
18 pub yes: bool,
19 /// `--context NAME` — deploy this context alone, assuming the contexts it
20 /// consumes are already live (slice 2, D4). Absent deploys the whole
21 /// project in dependency order.
22 pub context: Option<String>,
23 /// `--env NAME` — slice 4. Selects the `bynk.deploy.lock` section; for any
24 /// value other than `"default"` also drives synthesis of an environment-
25 /// scoped Wrangler config section, since Cloudflare does not inherit
26 /// bindings into a named environment (confirmed against Cloudflare's own
27 /// docs — see #835). `DeployOptions::default()`'s empty string is a
28 /// test-only artefact of `#[derive(Default)]`; every real invocation goes
29 /// through the CLI, whose `default_value = "default"` guarantees this is
30 /// never empty.
31 pub environment: String,
32 /// `--secrets-file` — a dotenv-style source of `NAME=value` pairs. Supplies
33 /// **names and values** (slice 3, ADR 0195 D3).
34 pub secrets_file: Option<std::path::PathBuf>,
35 /// `--secret NAME` — a name whose *value* comes from the environment or a
36 /// prompt. The environment is never scanned for names, so this is how a
37 /// `bynk.Secrets` name reaches `deploy` without a file.
38 pub secrets: Vec<String>,
39 /// `--force` — overwrite a secret already set, rather than skipping it.
40 pub force: bool,
41 /// `--prune` (slice 5) — delete every reported KV/queue orphan, behind
42 /// its own confirmation. Never deletes a Worker (DECISION C). Defaults
43 /// to report-only, matching the track's "report or prevent, never
44 /// silently share/destroy" posture (§6).
45 pub prune: bool,
46 pub wrangler_args: Vec<String>,
47}
48
49/// The whole-project plan (slice 2). `order` is the upload order, dependencies
50/// first; `contexts` carries it with each context's own actions. Slice 0's
51/// single-worker `Plan` is the one-element case of this.
52#[derive(Debug, Serialize)]
53pub(crate) struct Plan<'a> {
54 environment: &'a str,
55 /// Slice 5: every ledger entry this environment no longer declares —
56 /// printed before the per-context breakdown, so an orphan is seen before
57 /// what's actually being pushed.
58 orphans: Orphans,
59 /// The resolved upload order — the plan's headline, since Cloudflare
60 /// rejects a Worker uploaded before its binding target.
61 order: Vec<&'a str>,
62 contexts: Vec<ContextPlan<'a>>,
63}
64
65#[derive(Debug, Serialize)]
66struct ContextPlan<'a> {
67 worker: &'a str,
68 kv: Option<PlanKv<'a>>,
69 /// One line per queue this context consumes, in name order.
70 queues: Vec<PlanQueue<'a>>,
71 /// #1796: one line per Durable Object class the push declares in
72 /// `exports`, in class-name order. Empty for a context with no agent and
73 /// no events fan-out.
74 durable_objects: Vec<PlanDurableObject<'a>>,
75 /// One line per secret this run will set on this context, in name order.
76 secrets: Vec<PlanSecret>,
77 /// False when this context names at least one secret with a computed
78 /// expression, so `secrets` is **not** everything it reads (ADR 0196 D2).
79 ///
80 /// Carried in the machine surface as well as the human one, because this is
81 /// the field that stops a CI job trusting a short list — the failure the
82 /// whole increment exists to prevent is a reader taking silence for absence.
83 secrets_complete: bool,
84 /// `deploy` first time, `redeploy` when the ledger has pushed it before —
85 /// the honest word, since a re-run re-pushes rather than skipping.
86 action: &'static str,
87 /// The workers this one binds to, in the emitted config.
88 binds_to: Vec<&'a str>,
89}
90
91#[derive(Debug, Serialize)]
92struct PlanKv<'a> {
93 action: &'static str,
94 namespace: &'a str,
95}
96
97#[derive(Debug, Serialize)]
98struct PlanQueue<'a> {
99 /// `create` when this project has never made the queue, `reuse` when the
100 /// ledger has it. Either way the provision step attempts the create and
101 /// treats an existing queue as success, so `reuse` is a forecast — "expect
102 /// nothing new" — not a promise to stay silent (ADR 0194 D2).
103 action: &'static str,
104 queue: &'a str,
105}
106
107/// One secret the run intends to set on one context.
108///
109/// There is deliberately **no presence field**. Presence is a live question
110/// (`wrangler secret list`), and the plan is derived before `deploy`
111/// authenticates — which is what keeps `--dry-run` working offline. So the plan
112/// says what it will *try*, and the run reports the skip when a secret turns out
113/// to be there. The ledger cannot help: it records no secret at all, because a
114/// recorded presence could only ever be a stale one (ADR 0195 D1/D4).
115#[derive(Debug, Serialize)]
116struct PlanSecret {
117 /// Owned: the name set is derived (declared ∪ supplied) rather than
118 /// borrowed from any one source.
119 name: String,
120 /// `declared` — the compiler proved a handler reads it. `supplied` — the
121 /// user named it. The mark is the floor-not-census contract made legible:
122 /// no `declared` line for a `bynk.Secrets` name does **not** mean the
123 /// context needs none (ADR 0195 D2).
124 origin: Origin,
125 /// `set`, or `overwrite` under `--force`. A `set` line may still report a
126 /// skip at wire time — see the type's note.
127 action: &'static str,
128}
129
130/// One Durable Object class the push declares (#1796). It replaces the
131/// migration-tag line ADR 0194 D1 described: with `exports` there is no tag,
132/// only a declared set that Cloudflare reconciles on every deploy.
133#[derive(Debug, Serialize)]
134struct PlanDurableObject<'a> {
135 class: &'a str,
136 /// The backend the config declares (`sqlite`), read from the file rather
137 /// than assumed, so the plan and the upload can't disagree.
138 storage: &'a str,
139 /// Always `Cloudflare`, and that is the point: the field names an owner
140 /// other than `bynk`, which is the whole content of the advisory (ADR 0194
141 /// D1's principle). A consumer reading the plan learns that this line is
142 /// not a claim that the namespace exists or doesn't, without having to
143 /// know the ADR.
144 reconciled_by: &'static str,
145}
146
147/// The `--` passthrough argument that conflicts with the driver's own
148/// `--env`, if any (slice 4, DECISION E) — bare or `=`-joined, mirroring
149/// `dev.rs`'s `passthrough_has` matching rule for the same class of clash
150/// (`--port`/`--inspector-port` there). Returns the matched literal, not just
151/// a bool, so the error can name what it conflicts with. Pure, so the rule is
152/// tested without touching `DeployOptions`.
153///
154/// `pub(crate)`: `dev.rs` reuses this for the identical clash between its own
155/// `--env` (which environment's ledger section `--remote` reads) and a
156/// `-- --env`/`-- --environment` passthrough to `wrangler dev` (which
157/// environment Wrangler actually connects to) — the same "two explicit,
158/// conflicting environment selections, one of them silent" shape, just
159/// without a value `dev` forwards to wrangler itself.
160pub(crate) fn conflicting_env_passthrough(wrangler_args: &[String]) -> Option<&str> {
161 wrangler_args
162 .iter()
163 .find(|arg| {
164 ["--env", "--environment"]
165 .iter()
166 .any(|flag| arg.as_str() == *flag || arg.starts_with(&format!("{flag}=")))
167 })
168 .map(String::as_str)
169}
170
171/// Run the slice-0 single-context deployment pipeline.
172pub fn run(
173 tb: &dyn Toolbox,
174 compiler: &Compiler,
175 project_root: &Path,
176 node_floor: u32,
177 opts: &DeployOptions,
178) -> ExitCode {
179 // Slice 4 (DECISION E), and the first check of all: once `--env` is a
180 // real, driver-curated concept, a conflicting `-- --env`/`-- --environment`
181 // would otherwise reach `wrangler deploy` as a second, contradictory flag —
182 // Wrangler's own last-wins parsing deciding silently which one actually
183 // deploys, while the ledger records the driver's choice regardless. Reject
184 // before any other work, rather than pick a winner between two explicit,
185 // conflicting inputs.
186 if let Some(conflict) = conflicting_env_passthrough(&opts.wrangler_args) {
187 eprintln!(
188 "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
189 opts.environment
190 );
191 return ExitCode::FAILURE;
192 }
193
194 let preflight_opts = DoctorOptions {
195 only: Some(Capability::Deploy),
196 strict: false,
197 };
198 let report = doctor::diagnose(
199 tb,
200 compiler,
201 &Context {
202 project_root: Some(project_root.to_path_buf()),
203 in_repo: false,
204 node_floor,
205 },
206 &preflight_opts,
207 );
208 if report.exit_nonzero(&preflight_opts) {
209 eprint!("{}", preflight_failure_message(&report));
210 return ExitCode::FAILURE;
211 }
212
213 let build_dir = project_root.join(".bynk").join("deploy");
214 if let Err(e) = workers::prepare_build_dir(project_root, &build_dir) {
215 eprintln!("bynk: could not prepare build directory: {e}");
216 return ExitCode::FAILURE;
217 }
218 if !workers::compile_once(compiler, project_root, &build_dir, true) {
219 return ExitCode::FAILURE;
220 }
221 // Slice 2: every context, ordered — not the one context slice 0 demanded.
222 let workers_dir = build_dir.join("workers");
223 let available = workers::discover_workers(&workers_dir);
224 let selected = match workers::select_contexts(&available, opts.context.as_slice()) {
225 Ok(selected) => selected,
226 Err(e) => {
227 eprintln!("bynk: {e}");
228 return ExitCode::FAILURE;
229 }
230 };
231 // Read spans the *whole* project even under `--context`: D4 needs the
232 // selected context's binding targets to check they are live, and they are
233 // by definition outside the selection.
234 let resources = match project_resources(&workers_dir, &available) {
235 Ok(resources) => resources,
236 Err(e) => {
237 eprintln!("bynk: {e}");
238 return ExitCode::FAILURE;
239 }
240 };
241 let graph = service_graph(&resources);
242 // Read before the plan: a malformed `--secrets-file` is the user's typo, and
243 // it should surface as one now rather than as a missing-secret failure
244 // partway through a run that has already pushed a Worker.
245 let secret_source = match SecretSource::read(opts) {
246 Ok(source) => source,
247 Err(e) => {
248 eprintln!("bynk: {e}");
249 return ExitCode::FAILURE;
250 }
251 };
252 let lock_path = project_root.join(LOCK_FILE);
253 let mut lock = match read_lock(&lock_path) {
254 Ok(lock) => lock,
255 Err(e) => {
256 eprintln!("bynk: could not read {}: {e}", lock_path.display());
257 return ExitCode::FAILURE;
258 }
259 };
260
261 // (D4) `--context` does not deploy a dependency closure. A binding to a
262 // Worker that has never been pushed fails at upload, so say which one
263 // rather than letting Cloudflare's own error carry it.
264 if opts.context.is_some()
265 && let [worker] = selected.as_slice()
266 {
267 let absent = absent_dependencies(worker, &graph, &lock, &opts.environment);
268 if !absent.is_empty() {
269 eprintln!(
270 "bynk: `{worker}` binds to {}, which {} never been deployed — a Service Binding to a Worker that does not exist fails at upload.",
271 absent
272 .iter()
273 .map(|a| format!("`{a}`"))
274 .collect::<Vec<_>>()
275 .join(", "),
276 if absent.len() == 1 { "has" } else { "have" }
277 );
278 eprintln!(" Deploy the whole project once (`bynk deploy`) to bring the topology up.");
279 return ExitCode::FAILURE;
280 }
281
282 // v0.177 (#643): the other half of D4. The dependency exists — but does
283 // it still provide the contract this worker was compiled against?
284 // Without this, the push succeeds and production discovers the skew by
285 // 409ing. `--context` is precisely the flag that makes this reachable.
286 let expects = match read_contracts_manifest(&workers_dir.join(worker)) {
287 Ok(m) => m.expects,
288 Err(e) => {
289 eprintln!(
290 "bynk: could not read `{worker}`'s {}: {e}",
291 bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
292 );
293 return ExitCode::FAILURE;
294 }
295 };
296 let skews = contract_skews(
297 &expects,
298 &lock,
299 bynk_emit::project::worker_dir_name,
300 &opts.environment,
301 );
302 if !skews.is_empty() {
303 eprintln!(
304 "bynk: `{worker}` was compiled against a contract its live dependencies no longer provide ({CONTRACT_SKEW}):"
305 );
306 for s in &skews {
307 eprintln!(
308 " {}.{} — compiled against {}, live is {}",
309 s.dependency, s.service, s.expected, s.live
310 );
311 }
312 eprintln!(
313 " Deploying this would ship a caller its callee rejects (409 ContractMismatch) on every call."
314 );
315 eprintln!(" Deploy the whole project (`bynk deploy`) so both sides move together.");
316 return ExitCode::FAILURE;
317 }
318 }
319
320 let order = match deploy_order(&selected, &graph) {
321 Ok(order) => order
322 .into_iter()
323 // A whole-project run orders every worker; `--context` orders only
324 // the selection, but the DFS reaches its (already-live) targets —
325 // drop them, D4 having already checked them.
326 .filter(|worker| selected.contains(worker))
327 .collect::<Vec<_>>(),
328 Err(e) => {
329 eprintln!("bynk: {e}");
330 return ExitCode::FAILURE;
331 }
332 };
333
334 let plan = derive_plan(
335 &order,
336 &resources,
337 &lock,
338 &secret_source,
339 opts.force,
340 &opts.environment,
341 );
342 print_plan(&plan, opts.format);
343 if opts.dry_run {
344 return ExitCode::SUCCESS;
345 }
346
347 // The CI gate is KV's alone, deliberately. A namespace id is *minted* by
348 // Cloudflare, so a CI job that creates one and cannot commit the result
349 // leaves an orphan nobody can find again. A queue's name comes from the
350 // source, so CI creating one loses nothing: the next run derives the same
351 // name and finds the same queue (ADR 0194 D2).
352 for worker in &order {
353 let recorded = recorded_kv(&lock, worker, &opts.environment);
354 if should_refuse_unrecorded_ci(resources[worker].needs_kv, recorded, is_ci()) {
355 eprintln!(
356 "bynk: KV namespace for `{worker}` is unrecorded; provision locally first and commit {LOCK_FILE}"
357 );
358 return ExitCode::FAILURE;
359 }
360 }
361 let probe = probe::detect(
362 tb,
363 "wrangler",
364 DetectOpts {
365 project_root: Some(project_root),
366 allow_npx: true,
367 },
368 );
369 // #1796: before authenticating, so the refusal costs no network call.
370 if let Some(refusal) = wrangler_floor_refusal(&probe, &order, &resources) {
371 eprintln!("{refusal}");
372 return ExitCode::FAILURE;
373 }
374 if !whoami(&probe.provenance) {
375 eprintln!(
376 "bynk: Cloudflare authentication is unavailable; run `wrangler login` or set CLOUDFLARE_API_TOKEN"
377 );
378 return ExitCode::FAILURE;
379 }
380 if !confirm(opts.yes) {
381 return ExitCode::FAILURE;
382 }
383
384 // Slice 5, DECISION B: once per run, not once per context — an
385 // account-wide list, fetched only when it could actually change an
386 // outcome (a context needs KV and already has a recorded id worth
387 // checking; a first deploy has nothing to check drift against). `None`
388 // (no wrangler, or the call failed) falls back to trusting the ledger
389 // unconditionally, exactly as every deploy before this slice did.
390 let live_kv_ids = if order
391 .iter()
392 .any(|w| resources[w].needs_kv && recorded_kv(&lock, w, &opts.environment).is_some())
393 {
394 live_kv_namespace_ids(&probe.provenance, project_root)
395 } else {
396 None
397 };
398
399 // Provision → wire → push, per context, in dependency order. Each context's
400 // state is written to the ledger as it lands (ADR 0180's incremental
401 // posture), so an interrupted multi-context run is resumable rather than
402 // restartable — and never rolled back (D2): a half-deployed project is a
403 // real state the next plan will show, not an error to unwind.
404 // Shared across the loop: a queue two contexts consume is one queue, so it
405 // wants one create attempt per run, not one per consumer (ADR 0194 D2).
406 let mut attempted_queues = BTreeSet::new();
407 // Shared across the loop so two contexts wanting the same secret prompt
408 // once. Dropped with the run — nothing here is ever written (ADR 0195 D1).
409 let mut resolved_secrets = BTreeMap::new();
410 for (i, worker) in order.iter().enumerate() {
411 if order.len() > 1 {
412 eprintln!("bynk: deploying `{worker}` ({}/{})…", i + 1, order.len());
413 }
414 match deploy_one(
415 &probe.provenance,
416 project_root,
417 &workers_dir,
418 worker,
419 &resources[worker],
420 &mut lock,
421 &lock_path,
422 &mut attempted_queues,
423 &mut Secrets {
424 source: &secret_source,
425 force: opts.force,
426 resolved: &mut resolved_secrets,
427 },
428 &opts.wrangler_args,
429 &opts.environment,
430 live_kv_ids.as_ref(),
431 ) {
432 Ok(Pushed::Ok) => {
433 // v0.177 (#643): record what this Worker now *provides*, so a
434 // later `--context` push of one of its callers can be refused
435 // before it ships a caller that would 409.
436 // `Some` even when empty: this build *knows* what the Worker
437 // provides, and "knows it provides nothing" must not read as
438 // "no record" at the next gate.
439 let provided = read_contracts_manifest(&workers_dir.join(worker))
440 .map(|m| Some(m.provides))
441 .unwrap_or(None);
442 lock.record_deployed(&opts.environment, worker, provided);
443 if let Err(e) = write_lock(&lock_path, &lock) {
444 eprintln!(
445 "bynk: deployed `{worker}` but could not record it in {}: {e}",
446 lock_path.display()
447 );
448 return ExitCode::FAILURE;
449 }
450 }
451 // A shared Ctrl-C. Stop, but report nothing and exit cleanly: the
452 // user asked for this, and the terminal signalled us too. `worker`
453 // is deliberately *not* recorded as deployed — the push was cut
454 // short, so whether it landed is unknown, and the ledger only ever
455 // claims what it watched succeed.
456 Ok(Pushed::Interrupted) => return ExitCode::SUCCESS,
457 Err(f) => {
458 eprintln!("bynk: {}", f.message);
459 // Stop rather than push on: everything left in the order either
460 // binds to what just failed or would be uploaded into a
461 // topology that is not what the plan described. `worker` itself
462 // is excluded — the line above already named it as the failure,
463 // and listing it here again as "not deployed" would double-count
464 // it against the number.
465 eprint!("{}", stopped_report(&order[i + 1..]));
466 // Wrangler's own code, not a flat 1 (slice 0's contract).
467 return ExitCode::from(f.code);
468 }
469 }
470 }
471
472 // Slice 5: pruning is project-wide, independent of `--context` — the
473 // orphan report already is (DECISION A), so pruning follows it. Runs
474 // only after every selected context has pushed cleanly: a failed deploy
475 // above already returned, so a mid-flight ledger never reaches this.
476 if opts.prune && plan.orphans.has_prunable() {
477 if !confirm_prune(opts.yes, &plan.orphans) {
478 return ExitCode::FAILURE;
479 }
480 if let Err(e) = prune_orphans(
481 &probe.provenance,
482 project_root,
483 &mut lock,
484 &lock_path,
485 &opts.environment,
486 &plan.orphans,
487 ) {
488 eprintln!("bynk: {}", e.message);
489 return ExitCode::from(e.code);
490 }
491 }
492 ExitCode::SUCCESS
493}
494
495/// #1796: the refusal a deploy gets when a context it pushes declares a
496/// Durable Object class and the wrangler that would push it is older than
497/// [`bynk_emit::WRANGLER_MIN`].
498///
499/// Below that floor wrangler doesn't read the `exports` map the emitter
500/// declares every class in, so the push would carry Durable Object bindings
501/// with no namespace behind them and fail with Cloudflare's own error, which
502/// says nothing about the version. `doctor`'s wrangler row only *warns* about
503/// the floor, because a project with no agent still deploys on an older
504/// wrangler. This is where the project is known, so this is where the floor
505/// becomes a hard requirement. `None` when nothing pushed declares a class, or
506/// when the version can't be judged without running npx
507/// ([`doctor::wrangler_below_min`]'s own rule).
508pub(crate) fn wrangler_floor_refusal(
509 probe: &probe::Probe,
510 order: &[String],
511 resources: &BTreeMap<String, Resources>,
512) -> Option<String> {
513 let version = probe
514 .version
515 .filter(|_| doctor::wrangler_below_min(probe))?;
516 let worker = order
517 .iter()
518 .find(|worker| !resources[*worker].durable_objects.is_empty())?;
519 Some(format!(
520 "bynk: wrangler {version} is older than {}, the first that reads the Durable Object \
521 `exports` `{worker}` declares; the push would fail. Upgrade with `{}`.",
522 bynk_emit::WRANGLER_MIN,
523 doctor::wrangler_upgrade_remedy(probe)
524 ))
525}
526
527pub fn preflight_failure_message(report: &Report) -> String {
528 format!(
529 "bynk: environment not ready for `deploy` — see below.\n\n{}",
530 report::render(report, Format::Human)
531 )
532}
533
534/// What the run did **not** get to, once a context failed. `rest` is the order
535/// *beyond* the failure, so the last context failing reports nothing — there was
536/// nothing left to withhold, and the failure itself has already been named.
537///
538/// Pure, so the wording — and the count's agreement with the list — is goldened
539/// rather than described.
540pub(crate) fn stopped_report(rest: &[String]) -> String {
541 if rest.is_empty() {
542 return String::new();
543 }
544 format!(
545 "bynk: stopping — {} not deployed: {}. Re-run `bynk deploy` to resume; what already landed is kept.\n",
546 if rest.len() == 1 {
547 "1 more context was".to_string()
548 } else {
549 format!("{} further contexts were", rest.len())
550 },
551 rest.join(", ")
552 )
553}
554
555/// Render the plan exactly as the user sees it. Pure, so the output surface the
556/// deploy guide documents is goldened rather than described — `print_plan` is
557/// the transport.
558pub(crate) fn plan_report(plan: &Plan<'_>, format: DeployFormat) -> String {
559 match format {
560 DeployFormat::Short => {
561 let mut out = String::new();
562 // Before the per-context section, deliberately: an orphan is a
563 // fact about the account regardless of what this run is about to
564 // do, and a reader should see it before the noise of what's being
565 // pushed (slice 5).
566 for kv in &plan.orphans.kv {
567 out.push_str(&format!("orphan kv {kv}\n"));
568 }
569 for worker in &plan.orphans.workers {
570 out.push_str(&format!("orphan worker {worker}\n"));
571 }
572 for queue in &plan.orphans.queues {
573 out.push_str(&format!("orphan queue {queue}\n"));
574 }
575 for context in &plan.contexts {
576 if let Some(kv) = &context.kv {
577 out.push_str(&format!("kv {} {}\n", kv.action, kv.namespace));
578 }
579 for queue in &context.queues {
580 out.push_str(&format!("queue {} {}\n", queue.action, queue.queue));
581 }
582 // Between the provisioning lines and the push, because that is
583 // where it happens: the `exports` declaration rides the config
584 // `wrangler deploy` reads rather than being a step of its own.
585 // Flagged advisory in place — a reader must not take it for a
586 // claim that the namespace does or doesn't exist yet (#1796,
587 // ADR 0194 D1's principle).
588 for object in &context.durable_objects {
589 out.push_str(&format!(
590 "durable object {} ({}; advisory — {} reconciles it)\n",
591 object.class, object.storage, object.reconciled_by
592 ));
593 }
594 // Before the lines it qualifies, not after: a reader who takes
595 // the list for the whole story is the failure this increment
596 // exists to prevent (ADR 0196 D2).
597 if !context.secrets_complete {
598 out.push_str(&format!(
599 "secrets incomplete {} (computes at least one name)\n",
600 context.worker
601 ));
602 }
603 // Names only, never values (ADR 0195 D1). The origin rides each
604 // line because the three are not equally known: `declared` is
605 // required, `read` is advisory, `supplied` is the user's word.
606 for secret in &context.secrets {
607 out.push_str(&format!(
608 "secret {} {} ({})\n",
609 secret.action,
610 secret.name,
611 secret.origin.label()
612 ));
613 }
614 out.push_str(&format!("{} {}\n", context.action, context.worker));
615 }
616 // The order is the plan's load-bearing claim once there is more
617 // than one context, so state it rather than leaving it implied by
618 // the line order above.
619 if plan.order.len() > 1 {
620 out.push_str(&format!("order {}\n", plan.order.join(" → ")));
621 }
622 out
623 }
624 DeployFormat::Json => {
625 format!(
626 "{}\n",
627 serde_json::to_string_pretty(plan).expect("plan serialises")
628 )
629 }
630 }
631}
632
633fn print_plan(plan: &Plan<'_>, format: DeployFormat) {
634 print!("{}", plan_report(plan, format));
635}
636
637/// Derive the plan over the resolved order. Pure, so the per-context breakdown
638/// and the ordering claim are unit-tested without a Cloudflare account.
639///
640/// Indexes `resources` rather than defending a miss: `order` ⊆ the workers it
641/// was read for, and the deploy loop indexes the same map anyway — so a
642/// tolerated miss here would only understate the plan a moment before the run
643/// panicked on it regardless.
644pub(crate) fn derive_plan<'a>(
645 order: &'a [String],
646 resources: &'a BTreeMap<String, Resources>,
647 lock: &DeployLock,
648 // Not borrowed into the plan: a `PlanSecret` owns its name, because the set
649 // is derived (declared ∪ supplied) rather than taken from either source.
650 secrets: &SecretSource,
651 force: bool,
652 environment: &'a str,
653) -> Plan<'a> {
654 Plan {
655 environment,
656 orphans: find_orphans(lock, environment, resources),
657 order: order.iter().map(String::as_str).collect(),
658 contexts: order
659 .iter()
660 .map(|worker| {
661 let declared = &resources[worker];
662 ContextPlan {
663 worker,
664 kv: declared.needs_kv.then(|| PlanKv {
665 action: if recorded_kv(lock, worker, environment).is_some() {
666 "reuse"
667 } else {
668 "create"
669 },
670 namespace: worker,
671 }),
672 queues: declared
673 .queues
674 .iter()
675 .map(|queue| PlanQueue {
676 action: if lock.has_queue(environment, queue) {
677 "reuse"
678 } else {
679 "create"
680 },
681 queue,
682 })
683 .collect(),
684 durable_objects: declared
685 .durable_objects
686 .iter()
687 .map(|object| PlanDurableObject {
688 class: &object.class,
689 storage: &object.storage,
690 reconciled_by: "Cloudflare",
691 })
692 .collect(),
693 secrets: wanted_secrets(
694 &declared.declared_secrets,
695 &declared.read_secrets,
696 secrets,
697 )
698 .into_iter()
699 .map(|want| PlanSecret {
700 name: want.name,
701 origin: want.origin,
702 // Presence is not knowable here — the plan runs
703 // before auth so `--dry-run` stays offline — so the
704 // action is what the run will attempt.
705 action: if force { "overwrite" } else { "set" },
706 })
707 .collect(),
708 secrets_complete: declared.reads_complete,
709 action: if lock.is_deployed(environment, worker) {
710 "redeploy"
711 } else {
712 "deploy"
713 },
714 binds_to: declared.binds_to.iter().map(String::as_str).collect(),
715 }
716 })
717 .collect(),
718 }
719}
720
721fn should_refuse_unrecorded_ci(needs_kv: bool, recorded: Option<&str>, ci: bool) -> bool {
722 needs_kv && recorded.is_none() && ci
723}
724
725pub(crate) fn requires_interactive_confirmation(yes: bool, stdin_is_terminal: bool) -> bool {
726 !yes && stdin_is_terminal
727}
728
729fn is_ci() -> bool {
730 std::env::var_os("CI").is_some_and(|value| value != "false")
731}
732
733fn confirm(yes: bool) -> bool {
734 if yes {
735 return true;
736 }
737 if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
738 eprintln!("bynk: refusing to mutate in a non-interactive session without --yes");
739 return false;
740 }
741 eprint!("Deploy to Cloudflare? [y/N] ");
742 let _ = io::stderr().flush();
743 let mut answer = String::new();
744 io::stdin().read_line(&mut answer).is_ok()
745 && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
746}
747
748#[cfg(test)]
749pub(crate) mod tests {
750 use super::*;
751 use crate::deploy::config::tests::project;
752 use crate::deploy::graph::tests::graph;
753 use crate::deploy::ledger::tests::{lock_with_deployed, with_kv, with_queue};
754
755 fn names(v: &[&str]) -> Vec<String> {
756 v.iter().map(|s| s.to_string()).collect()
757 }
758
759 /// `derive_plan` with no secret input and no `--force` — the shape every
760 /// test that predates slice 3 wants, so those tests keep saying what they
761 /// are about rather than restating two arguments they do not exercise.
762 pub(crate) fn plan_of<'a>(
763 order: &'a [String],
764 resources: &'a BTreeMap<String, Resources>,
765 lock: &DeployLock,
766 ) -> Plan<'a> {
767 derive_plan(
768 order,
769 resources,
770 lock,
771 &SecretSource::default(),
772 false,
773 "default",
774 )
775 }
776
777 // ---- v0.177 (#643): the deploy-time contract-skew gate ----
778
779 fn lock_with(worker: &str, contracts: &[(&str, &str)]) -> DeployLock {
780 let mut lock = DeployLock::default();
781 lock.record_deployed(
782 "default",
783 worker,
784 Some(
785 contracts
786 .iter()
787 .map(|(s, h)| (s.to_string(), h.to_string()))
788 .collect(),
789 ),
790 );
791 lock
792 }
793
794 fn expects(dep: &str, svc: &str, hash: &str) -> BTreeMap<String, BTreeMap<String, String>> {
795 BTreeMap::from([(
796 dep.to_string(),
797 BTreeMap::from([(svc.to_string(), hash.to_string())]),
798 )])
799 }
800
801 #[test]
802 fn conflicting_env_passthrough_finds_bare_and_equals_forms() {
803 assert_eq!(
804 conflicting_env_passthrough(&names(&["--env", "production"])),
805 Some("--env")
806 );
807 assert_eq!(
808 conflicting_env_passthrough(&names(&["--env=production"])),
809 Some("--env=production")
810 );
811 assert_eq!(
812 conflicting_env_passthrough(&names(&["--environment", "production"])),
813 Some("--environment")
814 );
815 assert_eq!(
816 conflicting_env_passthrough(&names(&["--minify"])),
817 None,
818 "an unrelated flag is not a conflict"
819 );
820 assert_eq!(conflicting_env_passthrough(&[]), None);
821 }
822
823 #[test]
824 fn plan_creates_or_reuses_kv_from_the_ledger() {
825 let order = names(&["api"]);
826 let declared = project(vec![("api", Resources::default().needs_kv())]);
827 let fresh = DeployLock::default();
828 assert_eq!(
829 plan_of(&order, &declared, &fresh).contexts[0]
830 .kv
831 .as_ref()
832 .unwrap()
833 .action,
834 "create"
835 );
836 assert_eq!(
837 plan_of(&order, &declared, &with_kv(DeployLock::default(), "api")).contexts[0]
838 .kv
839 .as_ref()
840 .unwrap()
841 .action,
842 "reuse"
843 );
844 assert!(
845 plan_of(
846 &order,
847 &project(vec![("api", Resources::default())]),
848 &fresh
849 )
850 .contexts[0]
851 .kv
852 .is_none(),
853 "a context declaring no KV gets no KV line"
854 );
855 }
856
857 // ---- #600 slice 1: queues and Durable Objects ----------------------
858
859 #[test]
860 fn plan_creates_or_reuses_a_queue_by_its_name() {
861 // Queues reconcile on the name `from queue("n")` gave them — there is
862 // no id — so the ledger's whole answer is "have we made this before?"
863 let order = names(&["jobs"]);
864 let declared = project(vec![("jobs", Resources::default().consumes(&["intake"]))]);
865 let line =
866 |lock: &DeployLock| plan_of(&order, &declared, lock).contexts[0].queues[0].action;
867 assert_eq!(line(&DeployLock::default()), "create");
868 assert_eq!(line(&with_queue(DeployLock::default(), "intake")), "reuse");
869 // The name is keyed environment-wide, not per worker: a different
870 // context consuming `intake` means the same queue.
871 assert!(with_queue(DeployLock::default(), "intake").has_queue("default", "intake"));
872 assert!(!with_queue(DeployLock::default(), "intake").has_queue("default", "other"));
873 }
874
875 #[test]
876 fn a_context_with_no_queues_gets_no_queue_lines() {
877 assert!(
878 plan_of(
879 &names(&["api"]),
880 &project(vec![("api", Resources::default())]),
881 &DeployLock::default(),
882 )
883 .contexts[0]
884 .queues
885 .is_empty()
886 );
887 }
888
889 #[test]
890 fn a_wrangler_below_the_floor_refuses_only_a_push_that_declares_a_durable_object() {
891 // #1796: `exports` is read from WRANGLER_MIN on, so below it a context
892 // with an agent can't deploy; one without still can.
893 let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).unwrap();
894 let wrangler = |version: probe::Version| probe::Probe {
895 tool: "wrangler".to_string(),
896 version: Some(version),
897 provenance: probe::Provenance::Path("/usr/bin/wrangler".into()),
898 };
899 // The newest version strictly below the floor, whatever the floor is.
900 let old = if min.patch > 0 {
901 probe::Version {
902 patch: min.patch - 1,
903 ..min
904 }
905 } else if min.minor > 0 {
906 probe::Version {
907 minor: min.minor - 1,
908 patch: 999,
909 ..min
910 }
911 } else {
912 probe::Version {
913 major: min.major - 1,
914 minor: 999,
915 patch: 999,
916 }
917 };
918 let resources = project(vec![
919 ("api", Resources::default()),
920 ("jobs", Resources::default().exports(&["JobLedger"])),
921 ]);
922 let both = names(&["api", "jobs"]);
923
924 let refusal = wrangler_floor_refusal(&wrangler(old), &both, &resources)
925 .expect("an agent-bearing push below the floor is refused");
926 for part in [
927 format!("wrangler {old}"),
928 bynk_emit::WRANGLER_MIN.to_string(),
929 "`jobs`".to_string(),
930 "npm install -g wrangler@4".to_string(),
931 ] {
932 assert!(refusal.contains(&part), "missing {part:?} in: {refusal}");
933 }
934
935 // No class in what's pushed (`--context api`), the floor itself, or an
936 // unversioned npx wrangler: nothing to refuse.
937 assert_eq!(
938 wrangler_floor_refusal(&wrangler(old), &names(&["api"]), &resources),
939 None
940 );
941 assert_eq!(
942 wrangler_floor_refusal(&wrangler(min), &both, &resources),
943 None
944 );
945 let npx = probe::Probe {
946 tool: "wrangler".to_string(),
947 version: None,
948 provenance: probe::Provenance::Npx,
949 };
950 assert_eq!(wrangler_floor_refusal(&npx, &both, &resources), None);
951 }
952
953 #[test]
954 fn the_durable_object_lines_are_advisory_in_every_ledger_state() {
955 // #1796, ADR 0194 D1's principle: Cloudflare reconciles the declared
956 // `exports` against the Worker's namespaces, so the plan says what the
957 // push will *declare* and never what already exists. A ledger that has
958 // deployed this context before must not change the lines — there is no
959 // state here for the ledger to have an opinion about.
960 let order = names(&["jobs"]);
961 let declared = project(vec![(
962 "jobs",
963 Resources::default().exports(&["JobLedger", "__EventsFanout"]),
964 )]);
965 for lock in [DeployLock::default(), lock_with_deployed(&["jobs"])] {
966 let plan = plan_of(&order, &declared, &lock);
967 let objects = &plan.contexts[0].durable_objects;
968 assert_eq!(
969 objects.iter().map(|o| o.class).collect::<Vec<_>>(),
970 ["JobLedger", "__EventsFanout"],
971 "every declared class gets a line, the fan-out class included"
972 );
973 for object in objects {
974 assert_eq!(object.storage, "sqlite");
975 assert_eq!(
976 object.reconciled_by, "Cloudflare",
977 "the plan names an owner other than bynk — that is the advisory"
978 );
979 }
980 }
981 // No agent, no Durable Object line.
982 assert!(
983 plan_of(
984 &names(&["api"]),
985 &project(vec![("api", Resources::default())]),
986 &DeployLock::default(),
987 )
988 .contexts[0]
989 .durable_objects
990 .is_empty()
991 );
992 }
993
994 // ---- #601 slice 2: `--context` dependency liveness (D4) ------------
995
996 #[test]
997 fn context_flag_names_a_dependency_that_was_never_deployed() {
998 let g = graph(&[("orders", &["payment"]), ("payment", &[])]);
999 assert_eq!(
1000 absent_dependencies("orders", &g, &DeployLock::default(), "default"),
1001 names(&["payment"]),
1002 "deploying orders alone would fail at upload — say which target is missing"
1003 );
1004 // Once payment is in the ledger, orders alone is fine.
1005 assert!(
1006 absent_dependencies("orders", &g, &lock_with_deployed(&["payment"]), "default")
1007 .is_empty()
1008 );
1009 // A worker with no bindings never has an absent dependency.
1010 assert!(absent_dependencies("payment", &g, &DeployLock::default(), "default").is_empty());
1011 }
1012
1013 #[test]
1014 fn the_plan_distinguishes_a_first_deploy_from_a_redeploy() {
1015 let order = names(&["api"]);
1016 let declared = project(vec![("api", Resources::default())]);
1017 assert_eq!(
1018 plan_of(&order, &declared, &DeployLock::default()).contexts[0].action,
1019 "deploy"
1020 );
1021 assert_eq!(
1022 plan_of(&order, &declared, &lock_with_deployed(&["api"])).contexts[0].action,
1023 "redeploy",
1024 "a re-run re-pushes rather than skipping, so the plan must not say `deploy`"
1025 );
1026 }
1027
1028 #[test]
1029 fn the_plan_carries_the_order_and_each_context_s_bindings() {
1030 let order = names(&["payment", "orders"]);
1031 let declared = project(vec![
1032 ("orders", Resources::default().binds(&["payment"])),
1033 ("payment", Resources::default()),
1034 ]);
1035 let plan = plan_of(&order, &declared, &DeployLock::default());
1036 assert_eq!(plan.order, vec!["payment", "orders"]);
1037 assert_eq!(plan.contexts[1].worker, "orders");
1038 assert_eq!(plan.contexts[1].binds_to, vec!["payment"]);
1039 assert!(plan.contexts[0].binds_to.is_empty());
1040 }
1041
1042 #[test]
1043 fn dry_run_and_ci_gates_do_not_reach_mutation() {
1044 assert!(
1045 DeployOptions {
1046 dry_run: true,
1047 ..Default::default()
1048 }
1049 .dry_run
1050 );
1051 assert!(should_refuse_unrecorded_ci(true, None, true));
1052 assert!(!should_refuse_unrecorded_ci(true, Some("id"), true));
1053 assert!(!should_refuse_unrecorded_ci(true, None, false));
1054 }
1055
1056 #[test]
1057 fn non_interactive_deploy_requires_yes() {
1058 assert!(!requires_interactive_confirmation(false, false));
1059 assert!(requires_interactive_confirmation(false, true));
1060 assert!(!requires_interactive_confirmation(true, false));
1061 }
1062
1063 #[test]
1064 fn matching_contracts_are_not_a_skew() {
1065 let lock = lock_with("app-b", &[("whoami", "317bdd3de84d2176")]);
1066 let found = contract_skews(
1067 &expects("app.b", "whoami", "317bdd3de84d2176"),
1068 &lock,
1069 |c| c.replace('.', "-"),
1070 "default",
1071 );
1072 assert!(found.is_empty(), "{found:?}");
1073 }
1074
1075 #[test]
1076 fn a_changed_contract_is_a_skew() {
1077 // The scenario the increment exists for: B was redeployed with a new
1078 // contract, and A still stamps the old one.
1079 let lock = lock_with("app-b", &[("whoami", "ffffffffffffffff")]);
1080 let found = contract_skews(
1081 &expects("app.b", "whoami", "317bdd3de84d2176"),
1082 &lock,
1083 |c| c.replace('.', "-"),
1084 "default",
1085 );
1086 assert_eq!(found.len(), 1);
1087 assert_eq!(found[0].dependency, "app.b");
1088 assert_eq!(found[0].service, "whoami");
1089 assert_eq!(found[0].expected, "317bdd3de84d2176");
1090 assert_eq!(found[0].live, "ffffffffffffffff");
1091 }
1092
1093 #[test]
1094 fn a_service_the_live_callee_no_longer_provides_is_a_skew() {
1095 let lock = lock_with("app-b", &[("somethingElse", "317bdd3de84d2176")]);
1096 let found = contract_skews(
1097 &expects("app.b", "whoami", "317bdd3de84d2176"),
1098 &lock,
1099 |c| c.replace('.', "-"),
1100 "default",
1101 );
1102 assert_eq!(found.len(), 1);
1103 assert_eq!(found[0].live, "<absent>");
1104 }
1105
1106 #[test]
1107 fn a_ledger_with_no_contract_record_yields_no_finding() {
1108 // Silence is not a match. A dependency deployed by a pre-v0.177 driver
1109 // has no contract record, and the gate must report only what it *knows*
1110 // is skewed — never what it merely cannot rule out. The runtime check is
1111 // the backstop for exactly this case, so a false accusation here would
1112 // block a legitimate deploy for no gain.
1113 let mut lock = DeployLock::default();
1114 lock.record_deployed("default", "app-b", None);
1115 let found = contract_skews(
1116 &expects("app.b", "whoami", "317bdd3de84d2176"),
1117 &lock,
1118 |c| c.replace('.', "-"),
1119 "default",
1120 );
1121 assert!(found.is_empty(), "{found:?}");
1122 }
1123
1124 #[test]
1125 fn a_callee_that_now_provides_nothing_is_a_total_skew() {
1126 // The counterpart to the rule above, and why the sentinel is `Option`
1127 // rather than an empty map: a callee that removed *all* its `on call`
1128 // services emits no manifest, so a bare-map field would record `{}` —
1129 // indistinguishable from "old ledger" — and the gate would wave through
1130 // the most complete skew there is. `Some({})` says "known to provide
1131 // nothing", which is a finding.
1132 let lock = lock_with("app-b", &[]);
1133 let found = contract_skews(
1134 &expects("app.b", "whoami", "317bdd3de84d2176"),
1135 &lock,
1136 |c| c.replace('.', "-"),
1137 "default",
1138 );
1139 assert_eq!(found.len(), 1);
1140 assert_eq!(found[0].live, "<absent>");
1141 }
1142
1143 #[test]
1144 fn a_never_deployed_dependency_yields_no_finding_here() {
1145 // That is D4's existing job (`absent_dependencies`), and it runs first.
1146 // Reporting it twice, in two vocabularies, would only confuse.
1147 let found = contract_skews(
1148 &expects("app.b", "whoami", "317bdd3de84d2176"),
1149 &DeployLock::default(),
1150 |c| c.replace('.', "-"),
1151 "default",
1152 );
1153 assert!(found.is_empty(), "{found:?}");
1154 }
1155}