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