Skip to main content

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}